Преглед на файлове

feat: 广告制作管理平台二期

完整的 Web 管理平台,供运营团队通过浏览器上传素材、配置主题、选择平台、一键生成 playable 广告。

## 目录重构
- 一期代码下沉至 templates/coloring/(独立 Vite 项目)
- 新增 platform/server(Express + SQLite + 构建服务)
- 新增 platform/client(React SPA)
- 新增 manifest.json 模板自描述文件,预留多模板扩展

## 模板参数化
- index.ts 收敛 10 行分散 import → 单行 #ad-config 入口
- vite.config.js 新增 #ad-config alias,环境变量 AD_CONFIG_PATH 切换
- ad-config.ts 提供默认素材/主题,平台构建时动态生成 _ad_config_.ts
- 支持 optional 素材(special.jpeg),构建时按存在性拼接 import

## 核心功能
- 创意管理:新建/编辑/删除,选择模板,上传素材 zip
- URL 素材导入:输入填色详情页 URL,自动下载加密 zip → XOR 解密 → 解压
- 主题配置:渐变色取色器 + 原始值编辑,背景/CTA/进度条颜色
- 多平台构建:Google/Applovin/Unity/Playturbo/Mintegral,异步队列串行化
- 产物下载:单文件 HTML / 全部 ZIP
- 实时预览:启动 Vite dev server → iframe 嵌入,主题变更 HMR 自动刷新

## 部署
- PM2 部署:git clone → npm install → pm2 start
- Docker 部署:docker build → docker run
- 构建产物纳入 Git(platform/*/dist/),支持 git pull 直接更新
guoziyun преди 3 седмици
родител
ревизия
6a0d237656
променени са 100 файла, в които са добавени 10176 реда и са изтрити 523 реда
  1. 13 0
      .dockerignore
  2. 34 12
      .gitignore
  3. 40 0
      Dockerfile
  4. 82 19
      README.md
  5. 0 472
      assets/css/setting.css
  6. 127 0
      docs/deploy.md
  7. 1097 0
      docs/phase2-plan.md
  8. 20 0
      ecosystem.config.js
  9. 19 20
      package.json
  10. 0 0
      platform/client/dist/assets/index-BRjdK5H3.css
  11. 8 0
      platform/client/dist/assets/index-CjzBYgHM.js
  12. 13 0
      platform/client/dist/index.html
  13. 12 0
      platform/client/index.html
  14. 1774 0
      platform/client/package-lock.json
  15. 23 0
      platform/client/package.json
  16. 17 0
      platform/client/src/App.tsx
  17. 78 0
      platform/client/src/api/client.ts
  18. 183 0
      platform/client/src/components/AssetUploader.module.css
  19. 191 0
      platform/client/src/components/AssetUploader.tsx
  20. 101 0
      platform/client/src/components/BuildHistory.module.css
  21. 102 0
      platform/client/src/components/BuildHistory.tsx
  22. 58 0
      platform/client/src/components/BuildPanel.module.css
  23. 117 0
      platform/client/src/components/BuildPanel.tsx
  24. 72 0
      platform/client/src/components/GradientEditor.module.css
  25. 108 0
      platform/client/src/components/GradientEditor.tsx
  26. 54 0
      platform/client/src/components/Layout.module.css
  27. 26 0
      platform/client/src/components/Layout.tsx
  28. 37 0
      platform/client/src/components/PlatformSelector.module.css
  29. 39 0
      platform/client/src/components/PlatformSelector.tsx
  30. 96 0
      platform/client/src/components/PreviewPanel.module.css
  31. 105 0
      platform/client/src/components/PreviewPanel.tsx
  32. 77 0
      platform/client/src/components/ThemeEditor.module.css
  33. 111 0
      platform/client/src/components/ThemeEditor.tsx
  34. 38 0
      platform/client/src/index.css
  35. 13 0
      platform/client/src/main.tsx
  36. 93 0
      platform/client/src/pages/CreativeDetail.module.css
  37. 146 0
      platform/client/src/pages/CreativeDetail.tsx
  38. 73 0
      platform/client/src/pages/Dashboard.module.css
  39. 77 0
      platform/client/src/pages/Dashboard.tsx
  40. 125 0
      platform/client/src/pages/NewCreative.module.css
  41. 121 0
      platform/client/src/pages/NewCreative.tsx
  42. 81 0
      platform/client/src/types/index.ts
  43. 6 0
      platform/client/src/vite-env.d.ts
  44. 21 0
      platform/client/tsconfig.json
  45. 15 0
      platform/client/vite.config.ts
  46. 3 0
      platform/server/dist/db/database.d.ts
  47. 1 0
      platform/server/dist/db/database.d.ts.map
  48. 71 0
      platform/server/dist/db/database.js
  49. 1 0
      platform/server/dist/db/database.js.map
  50. 3 0
      platform/server/dist/db/seed.d.ts
  51. 1 0
      platform/server/dist/db/seed.d.ts.map
  52. 29 0
      platform/server/dist/db/seed.js
  53. 1 0
      platform/server/dist/db/seed.js.map
  54. 2 0
      platform/server/dist/index.d.ts
  55. 1 0
      platform/server/dist/index.d.ts.map
  56. 51 0
      platform/server/dist/index.js
  57. 1 0
      platform/server/dist/index.js.map
  58. 3 0
      platform/server/dist/middleware/errorHandler.d.ts
  59. 1 0
      platform/server/dist/middleware/errorHandler.d.ts.map
  60. 12 0
      platform/server/dist/middleware/errorHandler.js
  61. 1 0
      platform/server/dist/middleware/errorHandler.js.map
  62. 4 0
      platform/server/dist/routes/assets.d.ts
  63. 1 0
      platform/server/dist/routes/assets.d.ts.map
  64. 148 0
      platform/server/dist/routes/assets.js
  65. 0 0
      platform/server/dist/routes/assets.js.map
  66. 6 0
      platform/server/dist/routes/builds.d.ts
  67. 1 0
      platform/server/dist/routes/builds.d.ts.map
  68. 140 0
      platform/server/dist/routes/builds.js
  69. 0 0
      platform/server/dist/routes/builds.js.map
  70. 6 0
      platform/server/dist/routes/creatives.d.ts
  71. 1 0
      platform/server/dist/routes/creatives.d.ts.map
  72. 182 0
      platform/server/dist/routes/creatives.js
  73. 0 0
      platform/server/dist/routes/creatives.js.map
  74. 8 0
      platform/server/dist/routes/preview.d.ts
  75. 1 0
      platform/server/dist/routes/preview.d.ts.map
  76. 47 0
      platform/server/dist/routes/preview.js
  77. 1 0
      platform/server/dist/routes/preview.js.map
  78. 4 0
      platform/server/dist/routes/templates.d.ts
  79. 1 0
      platform/server/dist/routes/templates.d.ts.map
  80. 48 0
      platform/server/dist/routes/templates.js
  81. 1 0
      platform/server/dist/routes/templates.js.map
  82. 15 0
      platform/server/dist/services/buildService.d.ts
  83. 1 0
      platform/server/dist/services/buildService.d.ts.map
  84. 147 0
      platform/server/dist/services/buildService.js
  85. 0 0
      platform/server/dist/services/buildService.js.map
  86. 24 0
      platform/server/dist/services/configGenerator.d.ts
  87. 1 0
      platform/server/dist/services/configGenerator.d.ts.map
  88. 99 0
      platform/server/dist/services/configGenerator.js
  89. 0 0
      platform/server/dist/services/configGenerator.js.map
  90. 20 0
      platform/server/dist/services/previewService.d.ts
  91. 1 0
      platform/server/dist/services/previewService.d.ts.map
  92. 120 0
      platform/server/dist/services/previewService.js
  93. 0 0
      platform/server/dist/services/previewService.js.map
  94. 31 0
      platform/server/dist/services/storageService.d.ts
  95. 1 0
      platform/server/dist/services/storageService.d.ts.map
  96. 103 0
      platform/server/dist/services/storageService.js
  97. 0 0
      platform/server/dist/services/storageService.js.map
  98. 3088 0
      platform/server/package-lock.json
  99. 31 0
      platform/server/package.json
  100. 69 0
      platform/server/src/db/database.ts

+ 13 - 0
.dockerignore

@@ -0,0 +1,13 @@
+node_modules
+dist
+storage
+.git
+.claude
+.DS_Store
+*.log
+*.md
+docs
+agent.md
+test
+scripts
+decrypt-zip.js

+ 34 - 12
.gitignore

@@ -1,21 +1,43 @@
-# Add any directories, files, or patterns you don't want to be tracked by version control
-
+# Dependencies
 node_modules/
-public/bower/
-public/data
-public/app
-build
-dist/
-data/
+
+# Build output (only root and template dist are ignored)
+/dist/
+templates/*/dist/
+
+# Runtime data
+storage/
+
+# Generated build config
+templates/*/src/filler/_ad_config_.ts
+
+# Symlink to user assets (created at build time)
+templates/*/assets/user/
+
+# OS
 .DS_Store
+._*
+
+# Logs
 *.log
+
+# Archives
 *.gzip
 *.zip
-._*
-test/*.png
-test/*.svg
+
+# IDE
+.vscode/
+.idea/
+
+# Misc
+build
+data/
+public/bower/
+public/data
+public/app
 core.*
 report.*.json
-.vscode/
+test/*.png
+test/*.svg
 zorro/.vscode/
 .eslintrc.js

+ 40 - 0
Dockerfile

@@ -0,0 +1,40 @@
+# Playable Ads Platform - Production Image
+#
+# 包含:
+#   - Express API + 构建服务 (platform/server)
+#   - React 前端静态文件 (platform/client)
+#   - 填色模板 + 依赖 (templates/coloring) —— 供构建服务调用 vite build
+
+FROM node:20-alpine
+
+WORKDIR /app
+
+# ---- 安装模板依赖(构建服务需要 vite / vite-plugin-singlefile / typescript) ----
+COPY templates/coloring/package.json templates/coloring/package-lock.json ./templates/coloring/
+RUN cd templates/coloring && npm ci --production=false
+
+# ---- 安装 server 依赖 ----
+COPY platform/server/package.json platform/server/package-lock.json ./platform/server/
+RUN cd platform/server && npm ci --production=false
+
+# ---- 复制源码 ----
+COPY templates/coloring/ ./templates/coloring/
+COPY platform/server/   ./platform/server/
+COPY platform/client/dist/ ./platform/client/dist/
+
+# ---- 编译 server TypeScript ----
+RUN cd platform/server && npx tsc
+
+# ---- 清理 server devDependencies(减小镜像体积) ----
+# 保留 node_modules 用于运行时,templates 需要 vite 所以不能删
+RUN cd platform/server && npm prune --production
+
+# ---- 运行时目录 ----
+RUN mkdir -p /app/storage/creatives
+
+ENV PORT=3001
+ENV NODE_ENV=production
+
+EXPOSE 3001
+
+CMD ["node", "platform/server/dist/index.js"]

+ 82 - 19
README.md

@@ -281,32 +281,95 @@ A: 检查 `assets/res/` 下的图片资源,压缩大尺寸图片后重新构
 
 # 二期规划
 
-在当前一期的基础上进化出一个广告制作管理平台, 供广告运营制作团队使用, 目标是可以通过广告平台上传广告素材(或素材链接),指定广告平台, 输出目标文件(HTML、ZIP 等)
+在当前一期 playable 广告的基础上,构建一个 **广告制作管理平台**(Playable Ads Platform),供广告运营团队(5-10人)通过浏览器完成"上传素材 → 配置参数 → 选择平台 → 一键生成广告文件"的完整流程,无需接触源代码和命令行。
 
-## 主要功能
+> 详细技术方案见 [二期细化方案](./docs/phase2-plan.md)
 
-### 模版管理
+## 核心概念
 
-当前涉及游戏核心玩法的index.html是一个典型的广告模版, 后期可能还会制作更多的广告模版,比如主要展示内容列表的模版等等, 不一而足。
-广告管理平台应该能够管理这些模版, 能够在线预览。
+| 概念 | 说明 |
+|------|------|
+| **模板(Template)** | 可复用的广告框架。当前有 `coloring`(填色玩法),未来可扩展更多 |
+| **创意(Creative)** | 一个模板的实例 = 运营选择的模板 + 上传的素材 + 主题配置 + 构建产物 |
+| **素材包** | 运营上传的 `.zip` 文件,包含 `config.json`(必)、`page.png`(必)、`map.png`(必)、`special.jpeg`(可选) |
+| **构建产物** | 各广告平台的单文件 HTML,以及全部产物的 ZIP 包 |
 
-模版本身有默认的基础素材和参数, 但是要提炼出可以替换的东西,作为模版的参数,也是广告制作人员能够替换配置的内容。 比如当前核心玩法的广告模版, 可以替换的参数有:
+## 技术架构
 
-- 填色页素材, 即assets/res/ 目录下的图片资源(重要!)
-- logo 图片
-- 背景色
-- cta 按钮样式等
+```
+React SPA (浏览器)
+    │
+    ▼
+Express API (:3001)
+    ├── REST API (/api/v1/*)
+    ├── Build Service (调用 Vite 程序化构建)
+    ├── SQLite (creatives, builds, templates)
+    └── storage/creatives/<id>/  (素材 + 产物)
+```
+
+- **前端**:React + Vite + TypeScript
+- **后端**:Node.js / Express + TypeScript
+- **数据库**:SQLite (better-sqlite3)
+- **构建**:调用现有 Vite 项目,通过 `#ad-config` alias 注入用户素材和主题
+- **部署**:单体部署到 ECS,Express 同时服务 API + 静态文件
+
+## 项目结构
+
+```
+playableads-platform/
+├── templates/                  # 模板根目录(扩展点:新模板 = 新子目录)
+│   └── coloring/               #   填色玩法模板(当前一期代码)
+│       ├── manifest.json       #     模板元信息(驱动前后端 UI 和校验)
+│       ├── src/filler/         #     业务逻辑
+│       │   ├── index.ts        #       入口(从 #ad-config 导入素材+主题)
+│       │   ├── ad-config.ts    #       默认配置(dev / CLI 构建)
+│       │   └── _ad_config_.ts  #       gitignored,平台构建时动态生成
+│       └── assets/user/        #       gitignored,平台构建时 symlink → storage
+│
+├── platform/                   # 二期平台
+│   ├── server/                 #   Express API + Build Service
+│   └── client/                 #   React SPA
+│
+└── storage/                    # gitignored,运行时数据
+    ├── data.db                 #   SQLite
+    └── creatives/<uuid>/
+        ├── assets/             #   用户上传的 zip 解压产物
+        └── builds/<uuid>/      #   构建输出
+```
+
+## 运营操作流程
+
+```
+新建创意 → 选择模板(填色玩法)
+    │
+    ▼
+上传素材 zip(config.json + page.png + map.png ± special.jpeg)
+    │
+    ▼
+配置主题(背景渐变、CTA 文案/颜色、进度条颜色…)
+    │
+    ▼
+勾选目标平台(Google / Applovin / Unity / Playturbo / Mintegral)
+    │
+    ▼
+点击构建 → 等待完成 → 下载单平台 HTML 或全部产物 ZIP
+```
 
-### 广告制作
+## 模板参数化
 
-其流程大致如下:
+核心思路:**配置集中化 + Vite alias 切换**。
 
-- 选择广告模版
-- 配置广告参数(如填色页素材、logo 图片、背景色、cta 按钮样式等)
-- 选择广告平台
-- 生成广告文件(HTML、ZIP 等)
+- 模板 `index.ts` 从 `#ad-config` 单入口导入所有素材和主题
+- 日常 `npm run dev` → alias 指向 `ad-config.ts`(默认素材
+- 平台构建 → 环境变量 `AD_CONFIG_PATH=_ad_config_.ts`,alias 指向动态生成的配置
+- 每个模板通过 `manifest.json` 声明需要哪些素材、哪些主题参数,前端据此渲染 UI,后端据此校验
 
-## 难点
+## 实施阶段
 
-- 广告制作平台与当前项目的关系,模版的管理与使用。
-- 模版的参数化设计:需要设计好模版中哪些部分是可替换的,以及如何替换,保证灵活性的同时不增加使用难度。
+| 阶段 | 内容 | 预计 |
+|------|------|------|
+| 1. 基础搭建 | 目录重构、Express/React 脚手架、DB 初始化 | 2-3天 |
+| 2. 模板参数化 | index.ts 重构、#ad-config alias、dev/CLI 行为不变 | 1.5-2天 |
+| 3. 后端 API | 素材存取、构建服务、全部 API 端点 | 2-3天 |
+| 4. 前端 UI | Dashboard、CreativeDetail、构建交互 | 2-3天 |
+| 5. 部署 | Docker 化、ECS 部署、端到端测试 | 1-2天 |

+ 0 - 472
assets/css/setting.css

@@ -1,472 +0,0 @@
-.action-sheet {
-  position:fixed;
-  bottom: -1000px;
-  right:0;
-  left: 0;
-  z-index: 1000;
-  background: white;
-  box-shadow: 0 2px 4px 0 rgba(0,0,0,0.10);
-  border-radius: 6px 6px 0 0;
-  transition: bottom 0.5s;
-  
-}
-
-.action-sheet img {
-  width: 24px;
-  height: 24px;
-}
-
-.action-sheet p {
-  line-height: 22px;
-  font-size: 15px;
-  font-weight: 600;
-  font-family: Arial, Helvetica, sans-serif;
-  color: #333333;
-  padding-left: 20px;
-}
-
-.setting {
-  display: flex;
-  padding-left: 5.3%;
-  /* padding-bottom: 2%; */
-}
-
-.setting.hint {
-  margin-top: 18px;
-  margin-bottom: 15px;
-}
-
-.hint-list {
-  width: 83%;
-  margin: 0 auto;
-  white-space: nowrap;  /*控制不要换行*/
-  overflow: auto;
-/* Firefox hide scrollbar*/
-  scrollbar-width: none;
-}
-
-/*webkit browser hide scrollbar*/
-.hint-list::-webkit-scrollbar { 
-  display: none;
-}
-
-
-.hint-item {
-  display: inline-block;
-  width: 34px;
-  height: 34px;
-  border: 2px solid #EFEFF4;
-  border-radius: 50%;
-  margin-left: 5px;
-}
-
-.line {
-  width: 89.4%;
-  height: 1px;
-  background: #EFEFF4;
-  margin: 0 auto;
-  margin-top: 8px;
-  margin-bottom: 8px;
-}
-
-.setting.autonext {
-  margin-bottom: 10px;
-}
-
-
-/* The switch - the box around the slider */
-.switch {
-  position: absolute;
-  display: inline-block;
-  width: 40px;
-  height: 22px;
-  right: 6%;
-  -webkit-tap-highlight-color:transparent;  /*消除移动端的灰色背景闪烁*/
-}
-
-/* Hide default HTML checkbox */
-.switch input {
-  opacity: 0;
-  width: 0;
-  height: 0;
-}
-
-/* The slider */
-.slider {
-  position: absolute;
-  cursor: pointer;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background-color: #E5E5EA;
-  -webkit-transition: .4s;
-  transition: .4s;
-}
-
-.slider:before {
-  position: absolute;
-  content: "";
-  height: 20px;
-  width: 20px;
-  left: 1px;
-  bottom: 1px;
-  top: 1px;
-  background-color: white;
-  -webkit-transition: .4s;
-  transition: .4s;
-}
-
-input:checked + .slider {
-  background-color: #FF9500;
-}
-
-input:focus + .slider {
-  box-shadow: 0 0 1px #FF9500;
-}
-
-input:checked + .slider:before {
-  -webkit-transform: translateX(18px);
-  -ms-transform: translateX(18px);
-  transform: translateX(18px);
-}
-
-/* Rounded sliders */
-.slider.round {
-  border-radius: 10px;
-}
-
-.slider.round:before {
-  border-radius: 50%;
-}
-
-
-/**************************** AD 灯泡 *************************/
-.buld-circle-wrap {
-  position: absolute;
-  left: -100px;
-  top: 40%;
-  width: 50px;
-  height: 50px;
-  background: #C4C4C4;
-  z-index: 300;
-  /* background: transparent; */
-  border-radius: 50%;
-  /* border: 1px solid #cdcbd0; */
-  border: 1px solid transparent;
-  transition: left 0.5s;
-  animation: buld-scale 2s ease-in-out infinite;
-}
-
-@keyframes buld-scale{
-  0% { 
-    transform: scale(1);
-  }
-  50% {
-    transform: scale(1.1);
-  }
-  100% {
-    transform: scale(1);
-  }
-}
-
-@-webkit-keyframes buld-scale{
-  0% {
-    transform: scale(1);
-  }
-  50% {
-    transform: scale(1.1);
-  }
-  100% {
-    transform: scale(1);
-  }
-}
-
-
-.buld-circle-wrap .buld-circle .buld-mask,
-.buld-circle-wrap .buld-circle .buld-fill {
-  width: 50px;
-  height: 50px;
-  position: absolute;
-  border-radius: 50%;
-}
-
-.buld-mask .buld-fill {
-  clip: rect(0px, 25px, 50px, 0px);  /*rect (top, right, bottom, left) 左半边能看到 */
-  background-color: #1b9619;
-}
-
-.buld-circle-wrap .buld-circle .buld-mask {
-  clip: rect(0px, 50px, 50px, 25px); /*mask只能看到右半边*/
-}
-
-.buld-mask.buld-full,
-.buld-circle .buld-fill {
-  transition: transform 10s ease-in-out;
-  /* animation: buld-fill ease-in-out 10s;
-  transform: rotate(180deg); */
-}
-
-@keyframes buld-fill{
-  0% { 
-    transform: rotate(0deg);
-  }
-  100% {
-    transform: rotate(180deg);
-  }
-}
-
-@-webkit-keyframes buld-fill{
-  0% {
-    transform: rotate(0deg);
-  }
-  100% {
-    transform: rotate(180deg);
-  }
-}
-
-.buld-circle-wrap .buld-inside-circle {
-  position: absolute;
-  width: 42px;
-  height: 42px;
-  border-radius: 50%;
-  background: #EEB422;
-  line-height: 42px;
-  text-align: center;
-  margin-top: 4px;
-  margin-left: 4px;
-  color: black;
-  z-index: 500;
-  font-weight: 500;
-  font-size: 20px;
-  font-family: Arial, Helvetica, sans-serif;
-  box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.16);
-}
-
-.buld-wrapper {
-  position: absolute;
-  width: 78%;
-  height: 78%;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -45%);
-  z-index: 1000;
-  animation: buld-wrapper 2s ease-in-out infinite;
-}
-
-@keyframes buld-wrapper{
-  0% { 
-    top: 50%;
-  }
-  50% { 
-    top: 40%;
-  }
-  100% {
-    top: 50%;
-  }
-}
-
-@-webkit-keyframes buld-wrapper{
-  0% { 
-    top: 50%;
-  }
-  50% { 
-    top: 40%;
-  }
-  100% {
-    top: 50%;
-  }
-}
-
-.buld-bgimg {
-  position: absolute;
-  width: 100%;
-  height: 100%;
-}
-
-.buld-wrapper p {
-  position: absolute;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -75%);
-  font-size: 15px;
-  font-weight: 600;
-  font-family: Arial, Helvetica, sans-serif;
-}
-
-.shinning-mask {
-  position: absolute;
-  width: 78%;
-  height: 78%;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -50%);
-  border-radius: 50%;
-  background: #EEB422;
-  z-index: 600;
-}
-
-/* 光线 */
-.shinning {
-  position: absolute;
-  left:50%;
-  top: 50%;
-  width: 30px;
-  height: 1px;
-  margin: 0 auto;
-  background: #fcff62;
-  transform-origin: 0%;
-  z-index: 500;
-  animation: shinning 2s infinite;
-}
-
-@keyframes shinning{
-  0% { 
-    opacity: 0;
-  }
-  50% {
-    opacity: 1;
-  }
-  100% {
-    opacity: 0;
-  }
-}
-
-@-webkit-keyframes shinning{
-  0% { 
-    opacity: 0;
-  }
-  50% {
-    opacity: 1;
-  }
-  100% {
-    opacity: 0;
-  }
-}
-
-#shinning2 {
-  transform: rotate(-20deg);
-}
-
-#shinning3 {
-  transform: rotate(-40deg);
-}
-
-#shinning4 {
-  transform: rotate(-60deg);
-}
-
-#shinning5 {
-  transform: rotate(-80deg);
-}
-
-#shinning6 {
-  transform: rotate(-100deg);
-}
-
-#shinning7 {
-  transform: rotate(-120deg);
-}
-
-#shinning8 {
-  transform: rotate(-140deg);
-}
-
-#shinning9 {
-  transform: rotate(-160deg);
-}
-
-#shinning10 {
-  transform: rotate(-180deg);
-}
-
-.adtext {
-  position: absolute;
-  color: darkgray;
-  left: 50%;
-  top: 100%;
-  margin-top: 3px;
-  transform: translate(-50%);
-  font-size: 12px;
-  font-weight: 600;
-  font-family: Arial, Helvetica, sans-serif;
-}
-
-.remind {
-  position: absolute;
-  display: none;
-  right: 20px;
-  top: 5%;
-  width: 40px;
-  height: 40px;
-  z-index: 150;
-  transform: scale(0.1);
-}
-
-.remind-bg {
-  width: 100%;
-  height: 100%;
-}
-
-.remind p {
-  position: absolute;
-  left: 50%;
-  top: 43%;
-  transform: translate(-50%, -50%);
-  font-size: 15px;
-  font-weight: 600;
-  font-family: Arial, Helvetica, sans-serif;
-}
-
-.remind.animation {
-  /* transform-origin: 0; */
-  animation: reminder 2s;
-}
-
-@keyframes reminder{
-  0% { 
-    right: 50%;
-    top: 50%;
-    transform: scale(3);
-  }
-  40% {
-    right: 50%;
-    top: 50%;
-    transform: scale(1.5);
-  }
-  65% {
-    right: 50%;
-    top: 50%;
-    transform: scale(1.5);
-  }
-  100% {
-    right: 20px;
-    top: 5%;
-    transform: scale(0.1);
-  }
-}
-
-@-webkit-keyframes reminder{
-  0% { 
-    right: 50%;
-    top: 50%;
-    transform: scale(3);
-  }
-  40% {
-    right: 50%;
-    top: 50%;
-    transform: scale(1.5);
-  }
-  65% {
-    right: 50%;
-    top: 50%;
-    transform: scale(1.5);
-  }
-  100% {
-    right: 20px;
-    top: 5%;
-    transform: scale(0.1);
-  }
-}
-
-

+ 127 - 0
docs/deploy.md

@@ -0,0 +1,127 @@
+# 部署指南
+
+## 概述
+
+构建产物(`platform/server/dist/`、`platform/client/dist/`)已纳入 Git。服务器只需 `git pull` 即可获取最新代码和构建产物,无需 scp 或打包。
+
+## 本地:构建 + 推送
+
+```bash
+npm run build   # 构建 client + 编译 server
+git add .
+git commit -m "deploy: build"
+git push
+```
+
+## 服务器首次部署
+
+```bash
+# 1. Clone 代码
+git clone <repo-url> playableads-platform
+cd playableads-platform
+
+# 2. 安装依赖
+npm run install:all
+
+# 3. 创建日志目录
+mkdir -p logs
+
+# 4. 启动(PM2)
+pm2 start ecosystem.config.js
+pm2 save
+pm2 startup   # 开机自启(按提示执行输出的命令)
+```
+
+## 服务器更新
+
+```bash
+cd playableads-platform
+git pull
+# 如果 package.json 有变动:
+npm run install:all
+# 重启
+pm2 restart playableads-platform
+```
+
+## 自动化部署(Git Hook,可选)
+
+在服务器上配置 post-receive hook,`git push` 后自动触发更新:
+
+```bash
+# 服务器上
+cd playableads-platform
+
+cat > .git/hooks/post-receive << 'EOF'
+#!/bin/bash
+unset GIT_DIR
+cd /path/to/playableads-platform
+git pull origin master
+# 只在 package 有变动时安装
+if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep -q "package.json"; then
+  npm run install:all
+fi
+pm2 restart playableads-platform
+EOF
+
+chmod +x .git/hooks/post-receive
+```
+
+## 生产启动
+
+```bash
+# 本地验证
+npm run build && node platform/server/dist/index.js
+
+# 服务器(PM2)
+pm2 start ecosystem.config.js
+pm2 logs playableads-platform
+```
+
+## PM2 常用命令
+
+```bash
+pm2 list                    # 查看所有进程
+pm2 logs playableads-platform  # 查看日志
+pm2 restart playableads-platform  # 重启
+pm2 stop playableads-platform     # 停止
+pm2 monit                   # 实时监控 CPU/内存
+pm2 save                    # 保存当前进程列表(重启后恢复)
+pm2 startup                 # 设置开机自启
+```
+
+## Docker 部署(备选)
+
+```bash
+npm run docker:build   # 构建镜像
+npm run docker:run     # 启动容器(:3001,storage 挂载)
+
+# ECS
+docker save playableads-platform | gzip > playableads-platform.tar.gz
+scp playableads-platform.tar.gz ecs:~/
+ssh ecs
+docker load < playableads-platform.tar.gz
+mkdir -p ~/playableads-storage
+docker run -d -p 3001:3001 \
+  -v ~/playableads-storage:/app/storage \
+  --restart unless-stopped \
+  --name playableads playableads-platform
+```
+
+## 环境变量
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `PORT` | `3001` | 服务端口 |
+| `NODE_ENV` | `production` | pm2 中通过 ecosystem.config.js 设置 |
+
+## 运行时目录结构
+
+```
+playableads-platform/
+├── platform/server/dist/   ← 编译产物(git 跟踪)
+├── platform/client/dist/   ← 前端构建(git 跟踪)
+├── storage/                ← 运行时数据(gitignore)
+│   ├── data.db
+│   └── creatives/
+└── logs/                   ← PM2 日志
+```

+ 1097 - 0
docs/phase2-plan.md

@@ -0,0 +1,1097 @@
+# 二期:广告制作管理平台 — 细化方案 v2
+
+> 继承自 v1 规划,基于最新讨论更新:Creative 命名、zip 素材包、模板下沉、manifest 扩展性设计。
+
+---
+
+## 0. 与 v1 的关键变更
+
+| 维度 | v1 | v2 |
+|------|-----|-----|
+| 业务概念 | Project(项目) | **Creative(创意)**——广告行业标准术语 |
+| 素材上传 | 单文件逐个上传 | **zip 包上传**(config.json + page.png + map.png ± special.jpeg) |
+| 模板代码位置 | 留在项目根 `src/` | **下沉到 `templates/coloring/`** |
+| CSS 参数化 | 全面 `var()` 化 | **非重点**,聚焦主流程 |
+| storeUrls | 外部注入 | **不参数化**,保持 adapter 内硬编码 |
+| 模板元信息 | 无 | **manifest.json**,驱动前后端 UI + 校验 |
+| optional 素材 | 未明确 | **构建时检查文件存在性**,动态生成 import |
+
+---
+
+## 1. 总体架构
+
+```
+浏览器 (React SPA)
+    │
+    ▼
+Express API (:3001)
+    │  ┌── 静态文件服务 (React build)
+    │  ├── REST API (/api/v1/*)
+    │  └── Build Service (调用 Vite 程序化构建)
+    │
+    ├── SQLite (creatives, builds, templates 元数据)
+    │
+    ├── storage/creatives/<id>/
+    │     ├── assets/       # 用户上传的 zip 解压产物
+    │     └── builds/<uuid>/# 构建产物
+    │
+    └── templates/coloring/  # 模板 Vite 项目
+            │
+            ▼
+         npx vite build --mode <platform>
+```
+
+## 2. 项目目录结构
+
+```
+playableads-platform/
+│
+├── templates/                          # 模板根目录(扩展点)
+│   └── coloring/                       #   填色玩法模板
+│       ├── manifest.json               #     模板元信息 → 驱动前后端
+│       ├── index.html                  #     模板入口 HTML
+│       ├── vite.config.js              #     构建配置(含 #ad-config alias)
+│       ├── tsconfig.json
+│       ├── package.json                #     模板依赖(js-confetti, vite-plugin-singlefile…)
+│       ├── src/
+│       │   ├── base/                   #     自研 WebGL 2 引擎(不变)
+│       │   │   ├── Scene.ts
+│       │   │   ├── Gesture.ts
+│       │   │   ├── Animator.ts
+│       │   │   ├── m4.ts
+│       │   │   ├── BgLayer.ts / BoxLayer.ts / BorderLayer.ts / FrameLayer.ts
+│       │   │   ├── ImageLayer.ts / TextureLayer.ts
+│       │   │   ├── ImageShaders.ts
+│       │   │   ├── Triangle.ts / 2d.ts / utils.ts
+│       │   │   └── glsl/              # GLSL shader 文件
+│       │   ├── filler/                 #     填色玩法业务逻辑
+│       │   │   ├── index.ts            #       ★ 重构:从 #ad-config 导入素材+主题
+│       │   │   ├── ad-config.ts        #       NEW:默认配置(dev / CLI 构建用,提交到 repo)
+│       │   │   ├── _ad_config_.ts      #       .gitignore,平台构建时动态生成
+│       │   │   ├── FillerData.ts       #       状态核心
+│       │   │   ├── FillerScene.ts      #       WebGL 场景
+│       │   │   ├── WorkLayer.ts
+│       │   │   ├── AnimatableMask.ts
+│       │   │   ├── LineArtLayer.ts
+│       │   │   ├── NumberLayer.ts
+│       │   │   ├── HintLayer.ts
+│       │   │   ├── Mask.ts
+│       │   │   ├── Audio.ts
+│       │   │   ├── cta.ts
+│       │   │   ├── FingerHint.ts
+│       │   │   ├── explosion.ts
+│       │   │   ├── common.ts
+│       │   │   ├── createColored.ts
+│       │   │   ├── LoadingController.ts
+│       │   │   ├── play.ts             #       share.html 回放逻辑
+│       │   │   ├── mraid.ts
+│       │   │   └── ad-platform/
+│       │   │       ├── types.ts        #       AdPlatformAdapter 接口
+│       │   │       ├── current.ts      #       构建时 Vite alias 替换
+│       │   │       └── adapters/
+│       │   │           ├── google.ts
+│       │   │           ├── applovin.ts
+│       │   │           ├── unity.ts
+│       │   │           ├── playturbo.ts
+│       │   │           ├── helpers.ts
+│       │   │           └── storeUrls.ts
+│       │   └── utils/
+│       │       └── random.ts
+│       ├── assets/
+│       │   ├── res/                    #     默认填色素材(dev 用)
+│       │   │   └── 6a18f7d9957ac783bad75479/
+│       │   │       ├── config.json
+│       │   │       ├── page.png
+│       │   │       ├── map.png
+│       │   │       └── special.jpeg
+│       │   ├── user/                   #     .gitignore,平台构建时 symlink → storage
+│       │   ├── css/
+│       │   │   ├── tools.css           #       主样式
+│       │   │   └── loading.css         #       加载动画
+│       │   ├── img/
+│       │   │   ├── logo.png / logo-txt.png
+│       │   │   ├── coloring-pages.png / slogon.png
+│       │   │   ├── finger.png / finger2.png
+│       │   │   └── icon/
+│       │   ├── fonts/
+│       │   │   └── numbers_roboto_500.png
+│       │   └── sound/
+│       │       ├── color_done_02.mp3
+│       │       ├── section_done.mp3
+│       │       └── sound_hint.mp3
+│       ├── typings/                    #     TypeScript 声明
+│       └── dist/                       #     .gitignore
+│
+├── platform/                           # 二期平台代码
+│   ├── server/
+│   │   ├── package.json
+│   │   ├── tsconfig.json
+│   │   └── src/
+│   │       ├── index.ts                #     Express 入口
+│   │       ├── routes/
+│   │       │   ├── templates.ts        #     GET /api/v1/templates
+│   │       │   ├── creatives.ts        #     CRUD /api/v1/creatives
+│   │       │   ├── assets.ts           #     POST/DELETE /api/v1/creatives/:id/assets
+│   │       │   └── builds.ts           #     POST/GET /api/v1/creatives/:id/builds
+│   │       ├── services/
+│   │       │   ├── storageService.ts   #     素材存取、zip 解压、文件扫描
+│   │       │   ├── buildService.ts     #     构建编排(队列 + mutex)
+│   │       │   └── configGenerator.ts  #     生成 _ad_config_.ts + symlink
+│   │       ├── db/
+│   │       │   ├── database.ts         #     SQLite 初始化 + 迁移
+│   │       │   └── seed.ts             #     种子数据(coloring 模板注册)
+│   │       └── middleware/
+│   │           └── errorHandler.ts
+│   │
+│   └── client/
+│       ├── package.json
+│       ├── vite.config.ts
+│       ├── index.html
+│       └── src/
+│           ├── main.tsx                #     React 入口
+│           ├── App.tsx                 #     路由
+│           ├── api/
+│           │   └── client.ts           #     API 请求封装
+│           ├── pages/
+│           │   ├── Dashboard.tsx       #     创意列表 + 新建入口
+│           │   ├── NewCreative.tsx     #     选模板 → 填名称
+│           │   └── CreativeDetail.tsx  #     核心工作页(上传+配置+构建)
+│           ├── components/
+│           │   ├── Layout.tsx
+│           │   ├── AssetUploader.tsx   #     zip 上传 + 已上传文件列表
+│           │   ├── ThemeEditor.tsx     #     动态表单(由 manifest.theme 驱动)
+│           │   ├── PlatformSelector.tsx#     平台勾选
+│           │   ├── BuildPanel.tsx      #     构建按钮 + spinner + 产物下载
+│           │   ├── BuildHistory.tsx    #     历史构建记录
+│           │   └── PreviewModal.tsx    #     HTML 预览 iframe
+│           └── types/
+│               └── index.ts            #     Template, Creative, Build 类型
+│
+├── storage/                            # gitignored
+│   ├── data.db                         #   SQLite 数据库文件
+│   └── creatives/<uuid>/
+│       ├── assets/                     #   用户上传 zip 解压产物
+│       │   ├── config.json
+│       │   ├── page.png
+│       │   ├── map.png
+│       │   └── special.jpeg            #   可选
+│       └── builds/<build_uuid>/
+│           ├── google/
+│           │   └── index.html
+│           ├── applovin/
+│           │   └── index.html
+│           ├── unity/
+│           │   └── index.html
+│           ├── playturbo/
+│           │   └── index.html
+│           ├── mintegral/
+│           │   └── index.html
+│           └── all.zip                 #   全部产物打包
+│
+├── docs/
+│   └── GIT_GUIDELINES.md
+│
+├── share.html                          # 分享落地页(现有,保留)
+├── agent.md                            # Agent 工作文档
+├── README.md                           # 项目说明
+├── package.json                        # Root workspace orchestration
+├── .gitignore
+└── .claude/
+    ├── settings.local.json
+    └── plans/
+```
+
+### .gitignore 关键条目
+
+```
+# 平台构建时动态生成
+templates/*/src/filler/_ad_config_.ts
+templates/*/assets/user/
+
+# 运行时数据
+storage/
+platform/server/dist/
+platform/client/dist/
+dist/
+node_modules/
+```
+
+---
+
+## 3. 模板参数化
+
+### 3.1 manifest.json 规范
+
+每个模板目录根部的 `manifest.json` 是平台了解模板的唯一元数据来源。新增模板 = 新增目录 + 写一个 manifest,无需改平台代码。
+
+**`templates/coloring/manifest.json`:**
+
+```json
+{
+  "id": "coloring",
+  "name": "填色互动广告",
+  "description": "WebGL 填色核心玩法,点击区域上色,完成后展示宣传界面",
+  "version": "1.0.0",
+  "assets": {
+    "uploadFormat": "zip",
+    "required": [
+      { "key": "config", "file": "config.json", "label": "区域配置文件", "accept": ".json" },
+      { "key": "page",    "file": "page.png",    "label": "线稿图",       "accept": ".png,.jpg,.jpeg" },
+      { "key": "map",     "file": "map.png",     "label": "映射图",       "accept": ".png" }
+    ],
+    "optional": [
+      { "key": "special", "file": "special.jpeg", "label": "着色参考图(推荐)", "accept": ".jpeg,.jpg,.png" }
+    ]
+  },
+  "theme": {
+    "properties": [
+      { "key": "bgGradient",   "label": "背景渐变",      "type": "css-gradient", "default": "linear-gradient(160deg, #fff9f2, #fed)" },
+      { "key": "ctaGradient",  "label": "CTA 按钮渐变",  "type": "css-gradient", "default": "linear-gradient(135deg, #ff6b6b, #ee5a24)" },
+      { "key": "ctaText",      "label": "CTA 按钮文案",  "type": "text",         "default": "PLAY NOW", "maxLength": 30 },
+      { "key": "progressColor", "label": "进度条颜色",    "type": "color",        "default": "#07ce07" }
+    ]
+  },
+  "platforms": {
+    "available": ["google", "applovin", "unity", "playturbo", "mintegral"],
+    "defaults": ["google", "applovin"]
+  },
+  "build": {
+    "command": "npx vite build",
+    "cwd": "templates/coloring",
+    "outputPatterns": [
+      { "platform": "google",    "path": "dist/google/index.html" },
+      { "platform": "applovin",  "path": "dist/applovin/index.html" },
+      { "platform": "unity",     "path": "dist/unity/index.html" },
+      { "platform": "playturbo", "path": "dist/playturbo/index.html" },
+      { "platform": "mintegral", "path": "dist/mintegral/index.html" }
+    ],
+    "adConfigAlias": "#ad-config",
+    "adConfigPath": "src/filler/_ad_config_.ts",
+    "assetsSymlink": {
+      "source": "assets/user",
+      "target": "../../storage/creatives/{creativeId}/assets"
+    }
+  }
+}
+```
+
+### manifest 字段说明
+
+| 字段 | 用途 |
+|------|------|
+| `id` | 模板唯一标识,用于路由和数据库关联 |
+| `assets.uploadFormat` | `"zip"` 表示素材通过 zip 上传;未来可扩展 `"files"` 多文件 |
+| `assets.required[*]` | 必填素材清单——前端据此渲染上传区,后端据此校验 |
+| `assets.optional[*]` | 选填素材——前端标注"(选填)",后端检查存在性来生成条件 import |
+| `theme.properties[*]` | 主题参数——前端据此动态渲染表单控件 |
+| `platforms.available` | 此模板支持的目标平台 |
+| `platforms.defaults` | 默认勾选的平台 |
+| `build.outputPatterns` | 告诉平台构建产物在哪里,复制到哪里 |
+
+### 3.2 ad-config 接口
+
+模板 `index.ts` 的唯一数据入口:
+
+```ts
+// 类型定义(模板内)
+interface AdAssets {
+  configRaw: string;          // config.json 原始文本
+  pageUrl: string;            // 线稿图 URL
+  mapUrl: string;             // 映射图 URL
+  specialUrl?: string;        // 着色参考图(可选)
+  numberFontUrl: string;      // 数字字体图
+  fingerUrl: string;          // 手指引导图
+  logoUrl: string;            // Logo 图标
+  logoTxtUrl: string;         // Logo 文字
+  coloringPagesUrl: string;   // 宣传图1
+  slogonUrl: string;          // 宣传图2
+}
+
+interface AdTheme {
+  bgGradient: string;
+  ctaGradient: string;
+  ctaText: string;
+  progressColor: string;
+}
+```
+
+### 3.3 ad-config-default.ts(提交到仓库,dev/CLI 用)
+
+```ts
+// templates/coloring/src/filler/ad-config.ts
+import configRaw from "/assets/res/6a18f7d9957ac783bad75479/config.json?raw";
+import pageUrl from "/assets/res/6a18f7d9957ac783bad75479/page.png?url";
+import mapUrl from "/assets/res/6a18f7d9957ac783bad75479/map.png?url";
+import specialUrl from "/assets/res/6a18f7d9957ac783bad75479/special.jpeg?url";
+import numberFontUrl from "/assets/fonts/numbers_roboto_500.png?url";
+import fingerUrl from "/assets/img/finger.png?url";
+import logoUrl from "/assets/img/logo.png?url";
+import logoTxtUrl from "/assets/img/logo-txt.png?url";
+import coloringPagesUrl from "/assets/img/coloring-pages.png?url";
+import slogonUrl from "/assets/img/slogon.png?url";
+
+export const adAssets: AdAssets = {
+  configRaw, pageUrl, mapUrl, specialUrl,
+  numberFontUrl, fingerUrl, logoUrl, logoTxtUrl,
+  coloringPagesUrl, slogonUrl,
+};
+
+export const adTheme: AdTheme = {
+  bgGradient: "linear-gradient(160deg, #fff9f2, #fed)",
+  ctaGradient: "linear-gradient(135deg, #ff6b6b, #ee5a24)",
+  ctaText: "PLAY NOW",
+  progressColor: "#07ce07",
+};
+```
+
+### 3.4 _ad_config_.ts(平台构建时动态生成,gitignored)
+
+平台 `configGenerator.ts` 根据"用户上传了哪些文件" + "用户选择的主题参数"来生成:
+
+```ts
+// ↓ 以下为 configGenerator.ts 拼接生成 ↓
+
+// ==== 用户素材(从 storage/creatives/<id>/assets/ → symlink → assets/user/) ====
+import configRaw from "/assets/user/config.json?raw";
+import pageUrl from "/assets/user/page.png?url";
+import mapUrl from "/assets/user/map.png?url";
+// 仅当 special.jpeg 存在于用户上传包中时,才生成此行:
+import specialUrl from "/assets/user/special.jpeg?url";
+
+// ==== 模板自有素材 ====
+import numberFontUrl from "/assets/fonts/numbers_roboto_500.png?url";
+import fingerUrl from "/assets/img/finger.png?url";
+import logoUrl from "/assets/img/logo.png?url";
+import logoTxtUrl from "/assets/img/logo-txt.png?url";
+import coloringPagesUrl from "/assets/img/coloring-pages.png?url";
+import slogonUrl from "/assets/img/slogon.png?url";
+
+export const adAssets = {
+  configRaw, pageUrl, mapUrl,
+  // 仅当 special.jpeg 存在时才有此字段:
+  specialUrl,
+  numberFontUrl, fingerUrl, logoUrl, logoTxtUrl,
+  coloringPagesUrl, slogonUrl,
+};
+
+export const adTheme = {
+  bgGradient: "linear-gradient(160deg, #fff9f2, #fed)",
+  ctaGradient: "linear-gradient(135deg, #ff6b6b, #ee5a24)",
+  ctaText: "PLAY NOW",
+  progressColor: "#07ce07",
+};
+```
+
+### 3.5 index.ts 重构要点
+
+**当前 `loadResource()` (index.ts:251-275)**:11 行分散 import,hardcode 素材路径。
+
+**重构后**:
+
+```ts
+// templates/coloring/src/filler/index.ts
+import { adAssets, adTheme } from "#ad-config";
+
+async function loadResource(): Promise<FillerResource> {
+  const config = JSON.parse(adAssets.configRaw) as AreaGroups;
+  const [page, map, numberImage] = await Promise.all([
+    loadImage(adAssets.pageUrl),
+    loadImage(adAssets.mapUrl),
+    loadImage(adAssets.numberFontUrl),
+  ]);
+
+  // optional: 仅当用户上传了 special 时才尝试加载
+  let special: HTMLImageElement | undefined;
+  if ("specialUrl" in adAssets && adAssets.specialUrl) {
+    try {
+      special = await loadImage(adAssets.specialUrl);
+    } catch { /* fallback to createColored */ }
+  }
+
+  return new FillerResource(config, page, map, numberImage, [], special);
+}
+```
+
+### 3.6 vite.config.js 改动
+
+```js
+// templates/coloring/vite.config.js
+const { defineConfig } = require("vite");
+const { viteSingleFile } = require("vite-plugin-singlefile");
+const path = require("path");
+
+module.exports = defineConfig(({ mode }) => {
+  const platformBuild = platformBuilds[mode];
+  const adapter = platformBuild?.adapter || "google";
+  const output = platformBuild?.output;
+  const outDir = output ? `dist/${output}` : "dist";
+
+  // ★ 新增:#ad-config alias,通过环境变量切换
+  const adConfigPath = process.env.AD_CONFIG_PATH
+    ? path.resolve(__dirname, process.env.AD_CONFIG_PATH)
+    : path.resolve(__dirname, "src/filler/ad-config.ts");
+
+  return {
+    plugins: [viteSingleFile(), finalizeHtmlPlugin(outDir, adapter)],
+    resolve: {
+      alias: {
+        "#ad-config": adConfigPath,                // ★ 新增
+        "./ad-platform/current": path.resolve(
+          __dirname,
+          `src/filler/ad-platform/adapters/${adapter}.ts`,
+        ),
+      },
+    },
+    build: {
+      assetsInlineLimit: 100 * 1024 * 1024,
+      target: "es2017",
+      outDir,
+      emptyOutDir: true,
+      rollupOptions: {
+        input: { main: "./index.html" },
+        output: {
+          entryFileNames: "assets/[name].js",
+          chunkFileNames: "assets/[name].js",
+          assetFileNames: "assets/[name][extname]",
+        },
+      },
+    },
+  };
+});
+```
+
+改动极小:只加了一个 `#ad-config` alias,其他不变。
+
+---
+
+## 4. 数据库设计
+
+SQLite,五张表。
+
+### 4.1 ER 概览
+
+```
+templates  1──N  creatives  1──N  builds
+  │                                  │
+  └── manifest (JSON)                └── results (JSON)
+                  
+creatives.theme (JSON)
+creatives 1──N creative_assets (上传文件清单)
+```
+
+### 4.2 建表语句
+
+```sql
+-- 模板注册表
+CREATE TABLE templates (
+  id          TEXT PRIMARY KEY,          -- 'coloring'
+  name        TEXT NOT NULL,
+  manifest    TEXT NOT NULL,             -- manifest.json 全文
+  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+  updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- 创意表
+CREATE TABLE creatives (
+  id          TEXT PRIMARY KEY,          -- UUID
+  name        TEXT NOT NULL,             -- 运营人员命名的名称
+  template_id TEXT NOT NULL REFERENCES templates(id),
+  theme       TEXT NOT NULL DEFAULT '{}',-- JSON: 用户配置的主题参数
+  status      TEXT NOT NULL DEFAULT 'draft', -- draft | assets_ready | building | built | failed
+  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+  updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- 素材文件记录(从 zip 解压出来的文件清单)
+CREATE TABLE creative_assets (
+  id          INTEGER PRIMARY KEY AUTOINCREMENT,
+  creative_id TEXT NOT NULL REFERENCES creatives(id),
+  file_key    TEXT NOT NULL,             -- manifest 中定义的 key: 'config' | 'page' | 'map' | 'special'
+  file_name   TEXT NOT NULL,             -- 原始文件名
+  file_path   TEXT NOT NULL,             -- 相对 storage 的路径
+  file_size   INTEGER,                  -- 字节
+  is_required INTEGER NOT NULL DEFAULT 1,
+  created_at  TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- 构建记录表
+CREATE TABLE builds (
+  id          TEXT PRIMARY KEY,          -- UUID
+  creative_id TEXT NOT NULL REFERENCES creatives(id),
+  status      TEXT NOT NULL DEFAULT 'pending', -- pending | building | completed | failed
+  platforms   TEXT NOT NULL,             -- JSON: ["google","applovin"]
+  theme_snapshot TEXT NOT NULL,          -- JSON: 构建时的 theme 快照
+  results     TEXT,                      -- JSON: [{platform, filePath, fileSize}]
+  error_log   TEXT,
+  started_at  TEXT,
+  finished_at TEXT,
+  created_at  TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- 索引
+CREATE INDEX idx_creatives_template ON creatives(template_id);
+CREATE INDEX idx_builds_creative ON builds(creative_id);
+CREATE INDEX idx_creative_assets_creative ON creative_assets(creative_id);
+```
+
+### 4.3 状态流转
+
+```
+creatives:
+  draft ──→ assets_ready ──→ building ──→ built
+               │                 │
+               └──→ (re-upload)  └──→ failed (可重试)
+
+builds:
+  pending ──→ building ──→ completed
+                │
+                └──→ failed
+```
+
+---
+
+## 5. API 设计
+
+Base: `/api/v1`
+
+### 5.1 模板
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `GET` | `/templates` | 模板列表(含 manifest 中的 assets/theme/platforms 摘要) |
+| `GET` | `/templates/:id` | 单个模板详情(含完整 manifest) |
+
+**`GET /templates` 响应:**
+```json
+{
+  "data": [{
+    "id": "coloring",
+    "name": "填色互动广告",
+    "description": "WebGL 填色核心玩法",
+    "version": "1.0.0",
+    "platforms": ["google","applovin","unity","playturbo","mintegral"],
+    "assetCount": { "required": 3, "optional": 1 }
+  }]
+}
+```
+
+### 5.2 创意 CRUD
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `GET` | `/creatives` | 创意列表(支持 `?status=` 筛选,`?page=&limit=` 分页) |
+| `POST` | `/creatives` | 新建创意 |
+| `GET` | `/creatives/:id` | 创意详情(含 assets 列表、builds 摘要) |
+| `PATCH` | `/creatives/:id` | 更新创意名称或 theme |
+| `DELETE` | `/creatives/:id` | 删除创意及其所有 assets 和 builds |
+
+**`POST /creatives` 请求:**
+```json
+{
+  "name": "萌宠填色-谷歌渠道",
+  "templateId": "coloring"
+}
+```
+
+**`GET /creatives/:id` 响应:**
+```json
+{
+  "data": {
+    "id": "uuid-1",
+    "name": "萌宠填色-谷歌渠道",
+    "templateId": "coloring",
+    "template": { "id": "coloring", "name": "填色互动广告", "version": "1.0.0" },
+    "status": "assets_ready",
+    "theme": { "bgGradient": "...", "ctaText": "PLAY NOW" },
+    "assets": [
+      { "key": "config", "fileName": "config.json", "fileSize": 2048, "isRequired": true },
+      { "key": "page",    "fileName": "page.png",    "fileSize": 302000, "isRequired": true },
+      { "key": "map",     "fileName": "map.png",     "fileSize": 46000, "isRequired": true },
+      { "key": "special", "fileName": "special.jpeg","fileSize": 150000, "isRequired": false }
+    ],
+    "recentBuilds": [
+      { "id": "uuid-b1", "status": "completed", "platforms": ["google"], "createdAt": "..." }
+    ],
+    "createdAt": "2026-06-03T10:00:00Z",
+    "updatedAt": "2026-06-03T10:30:00Z"
+  }
+}
+```
+
+### 5.3 素材管理
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `POST` | `/creatives/:id/assets/upload` | 上传素材 zip(multipart/form-data) |
+| `DELETE` | `/creatives/:id/assets` | 清除当前创意所有素材 |
+
+**`POST /creatives/:id/assets/upload`:**
+- Content-Type: `multipart/form-data`
+- Field: `file` → `.zip` 文件
+- 后端解压 → 校验(对照 manifest.assets.required)→ 存入 `storage/creatives/:id/assets/`
+- 成功后创意状态 → `assets_ready`
+
+**响应:**
+```json
+{
+  "data": {
+    "files": [
+      { "key": "config", "fileName": "config.json", "fileSize": 2048, "valid": true },
+      { "key": "page",    "fileName": "page.png",    "fileSize": 302000, "valid": true },
+      { "key": "map",     "fileName": "map.png",     "fileSize": 46000, "valid": true }
+    ],
+    "warnings": []
+  }
+}
+```
+
+### 5.4 构建
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `POST` | `/creatives/:id/builds` | 触发构建 |
+| `GET` | `/creatives/:id/builds` | 构建历史 |
+| `GET` | `/builds/:id/status` | 构建状态(轮询) |
+| `GET` | `/builds/:id/download/:platform` | 下载单个平台 HTML |
+| `GET` | `/builds/:id/download/all` | 下载全部产物 ZIP |
+
+**`POST /creatives/:id/builds` 请求:**
+```json
+{
+  "platforms": ["google", "applovin", "unity"],
+  "theme": {
+    "bgGradient": "linear-gradient(160deg, #fff9f2, #fed)",
+    "ctaGradient": "linear-gradient(135deg, #ff6b6b, #ee5a24)",
+    "ctaText": "PLAY NOW",
+    "progressColor": "#07ce07"
+  }
+}
+```
+
+**响应:**
+```json
+{
+  "data": {
+    "id": "uuid-b1",
+    "status": "pending",
+    "platforms": ["google", "applovin", "unity"]
+  }
+}
+```
+
+**`GET /builds/:id/status` 响应(轮询用):**
+```json
+{
+  "data": {
+    "id": "uuid-b1",
+    "status": "completed",
+    "results": [
+      { "platform": "google",    "fileSize": 1024000 },
+      { "platform": "applovin",  "fileSize": 1025000 },
+      { "platform": "unity",     "fileSize": 1023000 }
+    ]
+  }
+}
+```
+
+---
+
+## 6. 构建服务
+
+### 6.1 构建流程
+
+```
+buildService.build(creativeId, buildId, platforms, theme)
+        │
+        ▼
+  ┌─ 1. 预检查 ─────────────────────────────────────┐
+  │  - 读取 manifest.json                            │
+  │  - 校验必填素材文件均存在                          │
+  │  - 校验 creative.status ∈ {assets_ready, built}   │
+  └──────────────────────────────────────────────────┘
+        │
+        ▼
+  ┌─ 2. 准备构建环境 ────────────────────────────────┐
+  │  - 创建/更新 symlink:                             │
+  │    templates/coloring/assets/user/                │
+  │    → storage/creatives/<creativeId>/assets/       │
+  │  - configGenerator.generate(creativeId, theme):  │
+  │    扫描 assets/ 目录,生成 _ad_config_.ts         │
+  │    包含条件 import(special 存在则导入)           │
+  └──────────────────────────────────────────────────┘
+        │
+        ▼
+  ┌─ 3. 构建循环(串行)─────────────────────────────┐
+  │  for each platform in platforms:                  │
+  │    $ cd templates/coloring                        │
+  │    $ AD_CONFIG_PATH=src/filler/_ad_config_.ts \   │
+  │      npx vite build --mode <platform>             │
+  │    → 生成 dist/<platform>/index.html              │
+  │    → 复制到 storage/creatives/<id>/builds/<bid>/  │
+  └──────────────────────────────────────────────────┘
+        │
+        ▼
+  ┌─ 4. 收尾 ───────────────────────────────────────┐
+  │  - 打包 all.zip(archiver)                       │
+  │  - 更新 builds 表 (status=completed, results)      │
+  │  - 更新 creatives 表 (status=built)               │
+  │  - 删除 _ad_config_.ts(可选保留 symlink)        │
+  └──────────────────────────────────────────────────┘
+```
+
+### 6.2 configGenerator 核心逻辑
+
+```ts
+// platform/server/src/services/configGenerator.ts
+import fs from "fs";
+import path from "path";
+
+interface GenerateConfigInput {
+  creativeId: string;
+  theme: Record<string, string>;
+  manifest: Manifest;
+}
+
+function generate(input: GenerateConfigInput): string {
+  const userAssetsDir = path.resolve(
+    __dirname, `../../../storage/creatives/${input.creativeId}/assets`
+  );
+  const files = fs.readdirSync(userAssetsDir);
+  const hasSpecial = files.includes("special.jpeg") || files.includes("special.jpg");
+
+  let imports = "";
+  let assetFields = "";
+
+  // Required assets (always present after validation)
+  imports += `import configRaw from "/assets/user/config.json?raw";\n`;
+  imports += `import pageUrl from "/assets/user/page.png?url";\n`;
+  imports += `import mapUrl from "/assets/user/map.png?url";\n`;
+  assetFields += "  configRaw, pageUrl, mapUrl,\n";
+
+  // Optional: special image
+  if (hasSpecial) {
+    const ext = files.find(f => f.startsWith("special."))!.split(".").pop();
+    imports += `import specialUrl from "/assets/user/special.${ext}?url";\n`;
+    assetFields += "  specialUrl,\n";
+  }
+
+  // Template-provided assets
+  imports += `import numberFontUrl from "/assets/fonts/numbers_roboto_500.png?url";\n`;
+  imports += `import fingerUrl from "/assets/img/finger.png?url";\n`;
+  imports += `import logoUrl from "/assets/img/logo.png?url";\n`;
+  // ... etc
+
+  return `${imports}
+export const adAssets = {
+${assetFields}  numberFontUrl, fingerUrl, logoUrl,
+  logoTxtUrl, coloringPagesUrl, slogonUrl,
+};
+
+export const adTheme = ${JSON.stringify(input.theme, null, 2)};
+`;
+}
+```
+
+### 6.3 构建串行化
+
+```ts
+// platform/server/src/services/buildService.ts
+class BuildService {
+  private queue: Array<() => Promise<void>> = [];
+  private running = false;
+
+  async enqueue(fn: () => Promise<void>) {
+    this.queue.push(fn);
+    if (!this.running) this.processQueue();
+  }
+
+  private async processQueue() {
+    this.running = true;
+    while (this.queue.length > 0) {
+      const task = this.queue.shift()!;
+      await task();
+    }
+    this.running = false;
+  }
+}
+```
+
+Vite build 自身就是单线程的,队列只需保证不并发写 `_ad_config_.ts`。
+
+### 6.4 超时与错误处理
+
+- 单次 `vite build` 超时:120s(正常构建 < 30s)
+- 构建失败:写 error_log,状态 → `failed`,不清除 symlink(方便排查)
+- 构建失败后允许用户在 Web UI 点"重试"
+
+---
+
+## 7. 前端设计
+
+### 7.1 路由
+
+```
+/                     Dashboard      创意列表卡片 + 新建入口
+/creatives/new        NewCreative    选模板 → 填名称
+/creatives/:id        CreativeDetail 上传素材 + 主题配置 + 构建
+```
+
+### 7.2 页面结构
+
+**Dashboard(`/`):**
+```
+┌─────────────────────────────────────────┐
+│  Playable Ads Platform          [+新建创意] │
+├─────────────────────────────────────────┤
+│  ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│  │ 创意1     │ │ 创意2     │ │ 创意3     │ │
+│  │ 模板:填色  │ │ 模板:填色  │ │ 模板:填色  │ │
+│  │ 状态:已就绪│ │ 状态:已构建│ │ 状态:草稿  │ │
+│  │ 3 files   │ │ 4 files   │ │ 0 files   │ │
+│  └──────────┘ └──────────┘ └──────────┘ │
+│                                         │
+│  (empty state: "暂无创意,点击新建")     │
+└─────────────────────────────────────────┘
+```
+
+**NewCreative(`/creatives/new`):**
+```
+┌─────────────────────────────────────────┐
+│  ← 返回                                  │
+│  新建创意                                │
+│                                         │
+│  选择模板:                              │
+│  ┌──────────────────────────┐ (选中✓)   │
+│  │ 🎨 填色互动广告            │          │
+│  │ WebGL 填色核心玩法         │          │
+│  └──────────────────────────┘           │
+│                                         │
+│  创意名称:[萌宠填色-谷歌渠道____]        │
+│                                         │
+│  [取消]  [创建]                          │
+└─────────────────────────────────────────┘
+```
+
+**CreativeDetail(`/creatives/:id`)—— 核心工作页:**
+
+```
+┌──────────────────────────────────────────────────────────┐
+│  ← 返回    萌宠填色-谷歌渠道    状态: ● 素材已就绪        │
+├──────────────────────────────────────────────────────────┤
+│                                                          │
+│  ┌─ 左栏:素材上传 ──────────┐ ┌─ 右栏:主题配置 ───────┐ │
+│  │                           │ │                        │ │
+│  │  📦 上传素材 zip           │ │  背景渐变              │ │
+│  │  [拖拽或点击上传]          │ │  [████████░░░░]        │ │
+│  │                           │ │                        │ │
+│  │  已上传文件 (3/3 必填):    │ │  CTA 渐变              │ │
+│  │  ✅ config.json   2 KB    │ │  [████████░░░░]        │ │
+│  │  ✅ page.png    302 KB    │ │                        │ │
+│  │  ✅ map.png      46 KB    │ │  CTA 文案              │ │
+│  │  ⬜ special.jpeg (选填)   │ │  [PLAY NOW       ]     │ │
+│  │                           │ │                        │ │
+│  │  [清除素材]                │ │  进度条颜色            │ │
+│  │                           │ │  [■] #07ce07           │ │
+│  └───────────────────────────┘ └────────────────────────┘ │
+│                                                          │
+│  ┌─ 构建 ───────────────────────────────────────────────┐ │
+│  │  目标平台:                                           │ │
+│  │  ☑ Google  ☑ Applovin  ☑ Unity  ☐ Playturbo  ☐ Mintegral │
+│  │                                                      │ │
+│  │  [🚀 开始构建]  (素材未上传时 disabled)               │ │
+│  │                                                      │ │
+│  │  ┌─ 构建历史 ───────────────────────────────────┐    │ │
+│  │  │ #3  2026-06-03 14:30  ✅ 完成                │    │ │
+│  │  │   [Google ↓] [Applovin ↓] [Unity ↓] [全部ZIP ↓] │   │ │
+│  │  │                                              │    │ │
+│  │  │ #2  2026-06-03 11:00  ❌ 失败                │    │ │
+│  │  │   错误: Required file 'map.png' missing      │    │ │
+│  │  └──────────────────────────────────────────────┘    │ │
+│  └──────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────┘
+```
+
+### 7.3 组件状态覆盖
+
+| 组件 | Loading | Empty | Error | Success |
+|------|---------|-------|-------|---------|
+| Dashboard | Skeleton cards | "暂无创意,点击新建" | "加载失败,点击重试" | 卡片列表 |
+| AssetUploader | 上传进度条 | "拖拽 zip 到此处" | "解压失败:缺少 config.json" | 文件清单 |
+| ThemeEditor | — | (manifest 无 theme 时隐藏整栏) | — | 表单控件 |
+| BuildPanel | 按钮 disabled+spinner | — | "构建失败:xxx" + 重试按钮 | 下载按钮 |
+| BuildHistory | Spinner 占位 | "暂无构建记录" | — | 构建列表 |
+
+### 7.4 状态管理
+
+前端状态简单,不需要 Redux 等状态库。使用 React hooks + 页面级 state:
+
+- `Dashboard` 自身管理创意列表
+- `CreativeDetail` 用 `useReducer` 管理一个页面的复杂状态:
+  - 创意基本信息
+  - 素材文件列表
+  - 主题表单值
+  - 构建状态 / 历史
+
+API 请求封装为 hooks:
+```ts
+useCreatives()           // → { data, loading, error, refetch }
+useCreative(id)          // → { data, loading, error }
+useBuilds(creativeId)    // → { data, loading, error, trigger }
+useBuildStatus(buildId)  // → { status, results }  轮询
+```
+
+---
+
+## 8. 实施阶段
+
+### 阶段 1:基础搭建(预计 2-3 天)
+
+**目标**:Express 启动、React 可访问、DB 初始化。
+
+| # | 任务 | 产出 |
+|---|------|------|
+| 1.1 | 重构目录结构:`src/` → `templates/coloring/`,创建 `platform/` | 目录就位 |
+| 1.2 | 创建 `templates/coloring/manifest.json` | manifest 文件 |
+| 1.3 | `platform/server/`:Express + SQLite + better-sqlite3 初始化 | server 可启动 |
+| 1.4 | 建表 + 种子数据(coloring 模板注册) | DB 就绪 |
+| 1.5 | `platform/client/`:Vite React + react-router-dom 脚手架 | dev server 可访问 |
+| 1.6 | 清理:删除 `setting.css` 引用(`index.html`)、标记 `Loader.ts` 为待移除 | 代码整洁 |
+
+**验收**:
+- `cd platform/server && npm run dev` → :3001 响应
+- `cd platform/client && npm run dev` → :5173 React 页面可访问
+- `GET /api/v1/templates` 返回 coloring 模板
+
+---
+
+### 阶段 2:模板参数化(预计 1.5-2 天)
+
+**目标**:`index.ts` 改为从 `#ad-config` 导入,dev/CLI 构建行为不变。
+
+| # | 任务 | 产出 |
+|---|------|------|
+| 2.1 | 创建 `ad-config.ts`(默认配置,内容 = 当前 11 行 import) | 默认配置就位 |
+| 2.2 | 重构 `index.ts`:分散 import → `import { adAssets, adTheme } from "#ad-config"` | `index.ts` 重构完成 |
+| 2.3 | `loadResource()` 适配:检查 `"specialUrl" in adAssets` | optional 处理 |
+| 2.4 | `vite.config.js` 加 `#ad-config` alias (+ 环境变量切换) | alias 就位 |
+| 2.5 | 验证:`npm run dev`(模板目录)行为不变 | dev 正常 |
+| 2.6 | 验证:`npm run build:all`(模板目录)产物不变 | 构建正常 |
+| 2.7 | 验证:`AD_CONFIG_PATH=src/filler/ad-config.ts npx vite build` 产物一致 | 环境变量切换正常 |
+| 2.8 | 移除 `Loader.ts`(未被任何代码引用) | 死代码清理 |
+
+**验收**:日常开发和 CLI 构建行为与重构前完全一致。`dist/` 下所有平台产物大小、功能无差异。
+
+---
+
+### 阶段 3:后端 API(预计 2-3 天)
+
+**目标**:curl 可触发完整"上传 zip → 构建 → 下载"流程。
+
+| # | 任务 | 产出 |
+|---|------|------|
+| 3.1 | `storageService`:zip 解压(adm-zip)、文件扫描、校验(对照 manifest) | 素材存取 |
+| 3.2 | `configGenerator`:根据文件列表 + theme 生成 `_ad_config_.ts` + 创建 symlink | 配置生成 |
+| 3.3 | `buildService`:构建队列 + mutex + `npx vite build` 调用 + 产物复制 + ZIP 打包 | 构建服务 |
+| 3.4 | `GET /templates` / `GET /templates/:id` | 模板 API |
+| 3.5 | `GET/POST /creatives` / `GET/PATCH/DELETE /creatives/:id` | 创意 CRUD |
+| 3.6 | `POST /creatives/:id/assets/upload` / `DELETE /creatives/:id/assets` | 素材 API |
+| 3.7 | `POST/GET /creatives/:id/builds` / `GET /builds/:id/status` / `GET /builds/:id/download/:platform` | 构建 API |
+| 3.8 | 错误处理 + 日志 | 稳定性 |
+
+**验收**:
+```bash
+# 1. 创建创意
+curl -X POST http://localhost:3001/api/v1/creatives \
+  -H "Content-Type: application/json" \
+  -d '{"name":"测试创意","templateId":"coloring"}'
+
+# 2. 上传素材
+curl -X POST http://localhost:3001/api/v1/creatives/<id>/assets/upload \
+  -F "file=@test-assets.zip"
+
+# 3. 触发构建
+curl -X POST http://localhost:3001/api/v1/creatives/<id>/builds \
+  -H "Content-Type: application/json" \
+  -d '{"platforms":["google","applovin"],"theme":{...}}'
+
+# 4. 轮询状态
+curl http://localhost:3001/api/v1/builds/<bid>/status
+
+# 5. 下载
+curl -O http://localhost:3001/api/v1/builds/<bid>/download/all
+```
+
+---
+
+### 阶段 4:前端 UI(预计 2-3 天)
+
+**目标**:浏览器端可完整体验"新建创意 → 上传素材 → 配置 → 构建 → 下载"。
+
+| # | 任务 | 产出 |
+|---|------|------|
+| 4.1 | API client 封装 + React Query hooks | 数据层 |
+| 4.2 | `Layout` + `Dashboard`(列表 + 空态 + 新建入口) | 首页 |
+| 4.3 | `NewCreative`(模板选择 + 名称输入) | 新建页 |
+| 4.4 | `CreativeDetail` — 左栏 `AssetUploader`(拖拽上传 zip + 文件列表 + 清除) | 素材区 |
+| 4.5 | `CreativeDetail` — 右栏 `ThemeEditor`(由 manifest.theme 驱动的动态表单) | 配置区 |
+| 4.6 | `CreativeDetail` — 底部 `PlatformSelector` + 构建按钮 + `BuildHistory` | 构建区 |
+| 4.7 | `BuildPanel`:触发构建 → 轮询状态(spinner)→ 完成显示下载按钮 | 构建交互 |
+| 4.8 | 各组件 loading / empty / error 状态覆盖 | 完整性 |
+| 4.9 | 响应式适配(Desktop 双栏,Mobile 上下堆叠) | 移动端可用 |
+
+**验收**:浏览器全流程可操作,各状态覆盖无白屏。
+
+---
+
+### 阶段 5:部署(预计 1-2 天)
+
+**目标**:ECS 可访问完整平台。
+
+| # | 任务 | 产出 |
+|---|------|------|
+| 5.1 | Client 生产构建(`vite build`)→ `platform/client/dist/` | 静态文件 |
+| 5.2 | Server 编译(`tsc`)→ Express 同时服务 API + 静态文件 | 单体部署 |
+| 5.3 | Dockerfile(Node 20 + 模板依赖预装) | Docker 化 |
+| 5.4 | ECS 部署 + 端口映射(3001) | 公网可访问 |
+| 5.5 | 端到端手动测试(上传 zip → 构建 → 下载 → 真机验证产物) | 质量确认 |
+
+**验收**:`http://42.193.231.145:3001` 可访问,完整流程无报错。
+
+---
+
+## 9. 风险清单
+
+| 风险 | 概率 | 影响 | 缓解措施 |
+|------|------|------|----------|
+| 用户上传的 zip 文件命名不规范(大小写、扩展名) | 高 | 低 | 文件匹配时忽略大小写和扩展名变体(`.jpeg`/`.jpg`、`.png`/`.PNG`) |
+| 大素材包导致 Vite build 超时(>120s) | 中 | 中 | 设置合理超时,前端提示"构建时间较长请耐心等待" |
+| 并发构建写 `_ad_config_.ts` 冲突 | 低 | 高 | Mutex 队列串行化,每 creative 同一时间只有 1 个构建 |
+| `storage/` 磁盘积累 | 中 | 低 | 保留最近 10 次构建产物;后续可加自动清理 cron |
+| WebGL 2 在低端 Android WebView 不支持 | 中 | 高 | 模板层面检测 + 静态降级画面(一期已有基础) |
+| Playturbo 扫描拒绝动态生成的 HTML | 中 | 中 | 构建后 finalize plugin 已处理 `type="module"` 移除(一期已验证) |
+| 模板目录结构变动后 vite alias 失效 | 低 | 中 | `vite.config.js` 使用 `path.resolve(__dirname, ...)` 绝对路径 |
+| optional 文件判断逻辑错误导致构建失败 | 中 | 中 | `configGenerator` 单元测试覆盖"有/无 special"两种场景 |
+
+---
+
+## 10. 后续扩展预留
+
+当前只实现 coloring 模板,但架构已预留多模板支持:
+
+1. **新增模板步骤**:
+   - `templates/<new-id>/` 下放置完整 Vite 项目
+   - 编写 `manifest.json`
+   - 在 DB `templates` 表中插入一行(或 seed 脚本中添加)
+   - 前端自动读取 manifest 渲染对应 UI
+
+2. **manifest 未来可扩展字段**:
+   - `previewImage`:模板缩略图,Dashboard 展示
+   - `build.env`:构建时需要注入的额外环境变量
+   - `assets.previewHint`:上传区的示例图
+   - `theme.groups`:主题参数分组(基础/高级)
+
+3. **平台功能后续可加**:
+   - 模板在线预览(iframe 嵌入模板 dev server)
+   - 构建产物在线预览(iframe 嵌入生成的 HTML)
+   - 批量构建(一个创意选多个平台一键构建)
+   - WebHook 通知(构建完成通知到企业微信/钉钉)

+ 20 - 0
ecosystem.config.js

@@ -0,0 +1,20 @@
+module.exports = {
+  apps: [
+    {
+      name: "playableads-platform",
+      script: "platform/server/dist/index.js",
+      cwd: __dirname,
+      env: {
+        NODE_ENV: "production",
+        PORT: 3001,
+      },
+      // 构建服务可能需要较多内存
+      max_memory_restart: "512M",
+      // 日志
+      out_file: "./logs/out.log",
+      error_file: "./logs/err.log",
+      merge_logs: true,
+      log_date_format: "YYYY-MM-DD HH:mm:ss",
+    },
+  ],
+};

+ 19 - 20
package.json

@@ -1,26 +1,25 @@
 {
-  "name": "test",
+  "name": "playableads-platform",
   "version": "1.0.0",
-  "description": "",
-  "main": "index.js",
+  "private": true,
+  "description": "Playable Ads Platform - 广告制作管理平台",
   "scripts": {
-    "dev": "npx vite --open --cors --host 0.0.0.0",
-    "build": "npx vite build",
-    "build:applovin": "npx vite build --mode applovin",
-    "build:unity": "npx vite build --mode unity",
-    "build:playturbo": "npx vite build --mode playturbo",
-    "build:mintegral": "npx vite build --mode mintegral",
-    "build:google": "npx vite build --mode google",
-    "build:all": "npm run build && npm run build:applovin && npm run build:unity && npm run build:playturbo && npm run build:mintegral && npm run build:google"
+    "dev": "node scripts/dev.js",
+    "dev:template": "cd templates/coloring && npx vite --open --cors --host 0.0.0.0",
+    "dev:server": "cd platform/server && npm run dev",
+    "dev:client": "cd platform/client && npm run dev",
+    "build": "npm run build:client && npm run build:server",
+    "build:template": "cd templates/coloring && npm run build:all",
+    "build:server": "cd platform/server && npm run build",
+    "build:client": "cd platform/client && npm run build",
+    "deploy:pack": "npm run build && tar -czf deploy.tar.gz --exclude='node_modules' --exclude='.git' --exclude='storage' --exclude='dist' --exclude='.DS_Store' platform/server/dist platform/server/package.json platform/server/package-lock.json platform/client/dist templates/coloring ecosystem.config.js scripts/install-deps.sh && echo 'deploy.tar.gz ready'",
+    "docker:build": "npm run build:client && docker build -t playableads-platform .",
+    "docker:run": "docker run -d -p 3001:3001 -v $(pwd)/storage:/app/storage --name playableads playableads-platform",
+    "install:all": "cd templates/coloring && npm install && cd ../../platform/server && npm install && cd ../client && npm install",
+    "install:template": "cd templates/coloring && npm install",
+    "install:server": "cd platform/server && npm install",
+    "install:client": "cd platform/client && npm install"
   },
   "author": "",
-  "license": "ISC",
-  "devDependencies": {
-    "typescript": "^5.5.4",
-    "vite": "^5.4.2",
-    "vite-plugin-singlefile": "^2.3.3"
-  },
-  "dependencies": {
-    "js-confetti": "^0.12.0"
-  }
+  "license": "ISC"
 }

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/client/dist/assets/index-BRjdK5H3.css


Файловите разлики са ограничени, защото са твърде много
+ 8 - 0
platform/client/dist/assets/index-CjzBYgHM.js


+ 13 - 0
platform/client/dist/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Playable Ads Platform</title>
+    <script type="module" crossorigin src="/assets/index-CjzBYgHM.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BRjdK5H3.css">
+  </head>
+  <body>
+    <div id="root"></div>
+  </body>
+</html>

+ 12 - 0
platform/client/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Playable Ads Platform</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 1774 - 0
platform/client/package-lock.json

@@ -0,0 +1,1774 @@
+{
+  "name": "playableads-platform-client",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "playableads-platform-client",
+      "version": "1.0.0",
+      "dependencies": {
+        "react": "^18.3.1",
+        "react-dom": "^18.3.1",
+        "react-router-dom": "^6.28.0"
+      },
+      "devDependencies": {
+        "@types/react": "^18.3.12",
+        "@types/react-dom": "^18.3.1",
+        "@vitejs/plugin-react": "^4.3.4",
+        "typescript": "^5.6.3",
+        "vite": "^5.4.11"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+      "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.29.7",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+      "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+      "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.7",
+        "@babel/generator": "^7.29.7",
+        "@babel/helper-compilation-targets": "^7.29.7",
+        "@babel/helper-module-transforms": "^7.29.7",
+        "@babel/helpers": "^7.29.7",
+        "@babel/parser": "^7.29.7",
+        "@babel/template": "^7.29.7",
+        "@babel/traverse": "^7.29.7",
+        "@babel/types": "^7.29.7",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+      "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.7",
+        "@babel/types": "^7.29.7",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+      "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.29.7",
+        "@babel/helper-validator-option": "^7.29.7",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-globals": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+      "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+      "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.29.7",
+        "@babel/types": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+      "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7",
+        "@babel/traverse": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+      "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+      "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+      "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.29.7",
+        "@babel/types": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+      "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.7"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+      "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+      "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+      "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.7",
+        "@babel/parser": "^7.29.7",
+        "@babel/types": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+      "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.7",
+        "@babel/generator": "^7.29.7",
+        "@babel/helper-globals": "^7.29.7",
+        "@babel/parser": "^7.29.7",
+        "@babel/template": "^7.29.7",
+        "@babel/types": "^7.29.7",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+      "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@remix-run/router": {
+      "version": "1.23.3",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
+      "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz",
+      "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz",
+      "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz",
+      "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz",
+      "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz",
+      "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz",
+      "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz",
+      "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz",
+      "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz",
+      "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz",
+      "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz",
+      "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz",
+      "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz",
+      "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz",
+      "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz",
+      "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz",
+      "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz",
+      "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz",
+      "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz",
+      "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz",
+      "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz",
+      "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz",
+      "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz",
+      "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz",
+      "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz",
+      "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.15",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "18.3.30",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz",
+      "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/prop-types": "*",
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "18.3.7",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+      "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^18.0.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.0",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.27",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.17.0"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.33",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
+      "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001793",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
+      "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.366",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz",
+      "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.47",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
+      "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.15",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.2"
+      },
+      "peerDependencies": {
+        "react": "^18.3.1"
+      }
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "6.30.4",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
+      "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.3"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.30.4",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
+      "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@remix-run/router": "1.23.3",
+        "react-router": "6.30.4"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.61.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
+      "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.61.0",
+        "@rollup/rollup-android-arm64": "4.61.0",
+        "@rollup/rollup-darwin-arm64": "4.61.0",
+        "@rollup/rollup-darwin-x64": "4.61.0",
+        "@rollup/rollup-freebsd-arm64": "4.61.0",
+        "@rollup/rollup-freebsd-x64": "4.61.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.61.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.61.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.61.0",
+        "@rollup/rollup-linux-arm64-musl": "4.61.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.61.0",
+        "@rollup/rollup-linux-loong64-musl": "4.61.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.61.0",
+        "@rollup/rollup-linux-ppc64-musl": "4.61.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.61.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.61.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.61.0",
+        "@rollup/rollup-linux-x64-gnu": "4.61.0",
+        "@rollup/rollup-linux-x64-musl": "4.61.0",
+        "@rollup/rollup-openbsd-x64": "4.61.0",
+        "@rollup/rollup-openharmony-arm64": "4.61.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.61.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.61.0",
+        "@rollup/rollup-win32-x64-gnu": "4.61.0",
+        "@rollup/rollup-win32-x64-msvc": "4.61.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.23.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    }
+  }
+}

+ 23 - 0
platform/client/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "playableads-platform-client",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "react-router-dom": "^6.28.0"
+  },
+  "devDependencies": {
+    "@types/react": "^18.3.12",
+    "@types/react-dom": "^18.3.1",
+    "@vitejs/plugin-react": "^4.3.4",
+    "typescript": "^5.6.3",
+    "vite": "^5.4.11"
+  }
+}

+ 17 - 0
platform/client/src/App.tsx

@@ -0,0 +1,17 @@
+import { Routes, Route } from "react-router-dom";
+import Layout from "./components/Layout";
+import Dashboard from "./pages/Dashboard";
+import NewCreative from "./pages/NewCreative";
+import CreativeDetail from "./pages/CreativeDetail";
+
+export default function App() {
+  return (
+    <Layout>
+      <Routes>
+        <Route path="/" element={<Dashboard />} />
+        <Route path="/creatives/new" element={<NewCreative />} />
+        <Route path="/creatives/:id" element={<CreativeDetail />} />
+      </Routes>
+    </Layout>
+  );
+}

+ 78 - 0
platform/client/src/api/client.ts

@@ -0,0 +1,78 @@
+const BASE = "/api/v1";
+
+async function request<T>(url: string, options?: RequestInit): Promise<T> {
+  const res = await fetch(`${BASE}${url}`, {
+    headers: { "Content-Type": "application/json" },
+    ...options,
+  });
+  if (!res.ok) {
+    const body = await res.json().catch(() => ({}));
+    throw new Error(body.error?.message || `HTTP ${res.status}`);
+  }
+  return res.json();
+}
+
+export const api = {
+  // Templates
+  getTemplates: () => request<{ data: any[] }>("/templates"),
+  getTemplate: (id: string) => request<{ data: any }>(`/templates/${id}`),
+
+  // Creatives
+  getCreatives: (params?: { status?: string }) => {
+    const qs = params ? "?" + new URLSearchParams(params).toString() : "";
+    return request<{ data: any[] }>(`/creatives${qs}`);
+  },
+  createCreative: (body: { name: string; templateId: string }) =>
+    request<{ data: any }>("/creatives", {
+      method: "POST",
+      body: JSON.stringify(body),
+    }),
+  getCreative: (id: string) => request<{ data: any }>(`/creatives/${id}`),
+  updateCreative: (id: string, body: { name?: string; theme?: Record<string, string> }) =>
+    request<{ data: any }>(`/creatives/${id}`, {
+      method: "PATCH",
+      body: JSON.stringify(body),
+    }),
+  deleteCreative: (id: string) =>
+    request<{ data: any }>(`/creatives/${id}`, { method: "DELETE" }),
+
+  // Assets
+  uploadAssets: (creativeId: string, file: File) => {
+    const formData = new FormData();
+    formData.append("file", file);
+    return fetch(`${BASE}/creatives/${creativeId}/assets/upload`, {
+      method: "POST",
+      body: formData,
+    }).then((res) => {
+      if (!res.ok) return res.json().then((b) => { throw new Error(b.error?.message); });
+      return res.json();
+    });
+  },
+  importFromUrl: (creativeId: string, url: string) =>
+    request<{ data: any }>(`/creatives/${creativeId}/assets/upload`, {
+      method: "POST",
+      body: JSON.stringify({ url }),
+    }),
+  clearAssets: (creativeId: string) =>
+    request<{ data: any }>(`/creatives/${creativeId}/assets`, { method: "DELETE" }),
+
+  // Preview
+  startPreview: (creativeId: string, theme?: Record<string, string>) =>
+    request<{ data: { url: string } }>(`/creatives/${creativeId}/preview/start`, {
+      method: "POST",
+      body: theme ? JSON.stringify({ theme }) : undefined,
+    }),
+  stopPreview: (creativeId: string) =>
+    request<{ data: any }>(`/creatives/${creativeId}/preview/stop`, { method: "POST" }),
+
+  // Builds
+  triggerBuild: (creativeId: string, body: { platforms: string[]; theme: Record<string, string> }) =>
+    request<{ data: any }>(`/creatives/${creativeId}/builds`, {
+      method: "POST",
+      body: JSON.stringify(body),
+    }),
+  getBuilds: (creativeId: string) =>
+    request<{ data: any[] }>(`/creatives/${creativeId}/builds`),
+  getBuildStatus: (buildId: string) =>
+    request<{ data: any }>(`/builds/${buildId}/status`),
+};

+ 183 - 0
platform/client/src/components/AssetUploader.module.css

@@ -0,0 +1,183 @@
+.wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+/* 模式切换 */
+.tabs {
+  display: flex;
+  border: 1px solid var(--color-border);
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.tab {
+  flex: 1;
+  padding: 8px 0;
+  border: none;
+  background: white;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--color-text-secondary);
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.tab:first-child {
+  border-right: 1px solid var(--color-border);
+}
+
+.tabActive {
+  background: var(--color-primary);
+  color: white;
+}
+
+/* URL 输入行 */
+.urlRow {
+  display: flex;
+  gap: 8px;
+}
+
+.urlInput {
+  flex: 1;
+  padding: 10px 14px;
+  border: 1px solid var(--color-border);
+  border-radius: 8px;
+  font-size: 13px;
+  outline: none;
+  font-family: monospace;
+}
+
+.urlInput:focus {
+  border-color: var(--color-primary);
+}
+
+.urlBtn {
+  padding: 10px 20px;
+  border: none;
+  border-radius: 8px;
+  background: var(--color-primary);
+  color: white;
+  font-size: 13px;
+  font-weight: 500;
+  white-space: nowrap;
+}
+
+.urlBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.dropZone {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  padding: 28px;
+  border: 2px dashed var(--color-border);
+  border-radius: var(--radius);
+  cursor: pointer;
+  transition: border-color 0.2s, background 0.2s;
+}
+
+.dropZone:hover {
+  border-color: var(--color-primary);
+  background: rgba(0, 113, 227, 0.03);
+}
+
+.fileInput {
+  display: none;
+}
+
+.dropIcon {
+  font-size: 32px;
+}
+
+.dropText {
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.dropHint {
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+
+.fileList {
+  background: #fafafa;
+  border-radius: 8px;
+  padding: 12px;
+}
+
+.fileListHeader {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 13px;
+  font-weight: 500;
+  margin-bottom: 8px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid var(--color-border);
+}
+
+.clearBtn {
+  background: none;
+  border: none;
+  color: var(--color-error);
+  font-size: 12px;
+}
+
+.fileItem {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 0;
+  font-size: 13px;
+}
+
+.fileOk { color: var(--color-success); }
+.fileMissing { color: var(--color-text-secondary); }
+
+.fileName {
+  font-family: monospace;
+  font-size: 12px;
+  background: #eee;
+  padding: 1px 6px;
+  border-radius: 3px;
+}
+
+.fileLabel {
+  flex: 1;
+  color: var(--color-text-secondary);
+  font-size: 12px;
+}
+
+.fileSize {
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+
+.warnings {
+  background: #fff3cd;
+  border: 1px solid #ffc107;
+  border-radius: 6px;
+  padding: 8px 12px;
+  font-size: 13px;
+  color: #856404;
+}
+
+.warnings p {
+  margin: 2px 0;
+}
+
+.error {
+  color: var(--color-error);
+  font-size: 13px;
+}
+
+.hint {
+  color: var(--color-warning);
+  font-size: 13px;
+}

+ 191 - 0
platform/client/src/components/AssetUploader.tsx

@@ -0,0 +1,191 @@
+import { useState } from "react";
+import { api } from "../api/client";
+import type { CreativeAsset, AssetDef } from "../types";
+import styles from "./AssetUploader.module.css";
+
+interface Props {
+  creativeId: string;
+  assets: CreativeAsset[];
+  assetDefs: { required: AssetDef[]; optional: AssetDef[] };
+  onUpdated: () => void;
+}
+
+type ImportMode = "file" | "url";
+
+export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated }: Props) {
+  const [mode, setMode] = useState<ImportMode>("file");
+  const [uploading, setUploading] = useState(false);
+  const [url, setUrl] = useState("");
+  const [uploadResult, setUploadResult] = useState<{
+    files: Array<{ key: string; fileName: string; fileSize: number; valid: boolean }>;
+    warnings: string[];
+  } | null>(null);
+  const [error, setError] = useState("");
+
+  // === 文件上传 ===
+  async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    setUploading(true);
+    setError("");
+    setUploadResult(null);
+
+    try {
+      const res = await api.uploadAssets(creativeId, file);
+      setUploadResult(res.data);
+      onUpdated();
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setUploading(false);
+      e.target.value = "";
+    }
+  }
+
+  // === URL 导入 ===
+  async function handleUrlImport() {
+    if (!url.trim()) return;
+
+    setUploading(true);
+    setError("");
+    setUploadResult(null);
+
+    try {
+      const res = await api.importFromUrl(creativeId, url.trim());
+      setUploadResult(res.data);
+      onUpdated();
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setUploading(false);
+    }
+  }
+
+  async function handleClear() {
+    if (!confirm("确认清除所有素材?")) return;
+    try {
+      await api.clearAssets(creativeId);
+      setUploadResult(null);
+      setUrl("");
+      onUpdated();
+    } catch (err: any) {
+      setError(err.message);
+    }
+  }
+
+  const allDefs = [...assetDefs.required, ...assetDefs.optional];
+  const hasAllRequired = assetDefs.required.every((def) =>
+    assets.some((a) => a.key === def.key)
+  );
+
+  return (
+    <div className={styles.wrapper}>
+      {/* 模式切换 */}
+      <div className={styles.tabs}>
+        <button
+          className={`${styles.tab} ${mode === "file" ? styles.tabActive : ""}`}
+          onClick={() => setMode("file")}
+        >
+          上传 ZIP
+        </button>
+        <button
+          className={`${styles.tab} ${mode === "url" ? styles.tabActive : ""}`}
+          onClick={() => setMode("url")}
+        >
+          素材 URL
+        </button>
+      </div>
+
+      {/* 文件上传 */}
+      {mode === "file" && (
+        <label className={styles.dropZone}>
+          <input
+            type="file"
+            accept=".zip"
+            onChange={handleFileUpload}
+            disabled={uploading}
+            className={styles.fileInput}
+          />
+          <span className={styles.dropIcon}>📦</span>
+          <span className={styles.dropText}>
+            {uploading ? "解压中…" : "拖拽或点击上传素材 zip"}
+          </span>
+          <span className={styles.dropHint}>
+            包含 config.json + page.png + map.png ± special.jpeg
+          </span>
+        </label>
+      )}
+
+      {/* URL 导入 */}
+      {mode === "url" && (
+        <div className={styles.urlRow}>
+          <input
+            type="url"
+            className={styles.urlInput}
+            placeholder="https://color2.jccytech.cn/app/zh/pages/detail/6a15..."
+            value={url}
+            onChange={(e) => setUrl(e.target.value)}
+            disabled={uploading}
+          />
+          <button
+            onClick={handleUrlImport}
+            disabled={!url.trim() || uploading}
+            className={styles.urlBtn}
+          >
+            {uploading ? "获取中…" : "导入"}
+          </button>
+        </div>
+      )}
+
+      {/* 上传结果 */}
+      {uploadResult && uploadResult.warnings.length > 0 && (
+        <div className={styles.warnings}>
+          {uploadResult.warnings.map((w, i) => (
+            <p key={i}>⚠️ {w}</p>
+          ))}
+        </div>
+      )}
+
+      {error && <p className={styles.error}>{error}</p>}
+
+      {/* 文件列表 */}
+      {assets.length > 0 && (
+        <div className={styles.fileList}>
+          <div className={styles.fileListHeader}>
+            <span>
+              已上传文件 ({assets.filter((a) => a.isRequired).length}/{assetDefs.required.length} 必填)
+            </span>
+            <button onClick={handleClear} className={styles.clearBtn}>
+              清除素材
+            </button>
+          </div>
+          {allDefs.map((def) => {
+            const asset = assets.find((a) => a.key === def.key);
+            const isRequired = assetDefs.required.some((r) => r.key === def.key);
+            return (
+              <div key={def.key} className={styles.fileItem}>
+                <span className={asset ? styles.fileOk : styles.fileMissing}>
+                  {asset ? "✅" : isRequired ? "❌" : "⬜"}
+                </span>
+                <span className={styles.fileName}>{def.file}</span>
+                <span className={styles.fileLabel}>
+                  {def.label} {!isRequired && "(选填)"}
+                </span>
+                {asset && (
+                  <span className={styles.fileSize}>
+                    {(asset.fileSize / 1024).toFixed(0)} KB
+                  </span>
+                )}
+              </div>
+            );
+          })}
+        </div>
+      )}
+
+      {!hasAllRequired && assets.length > 0 && (
+        <p className={styles.hint}>请上传包含所有必填文件的 zip 包</p>
+      )}
+    </div>
+  );
+}

+ 101 - 0
platform/client/src/components/BuildHistory.module.css

@@ -0,0 +1,101 @@
+.wrapper {
+  margin-top: 16px;
+}
+
+.title {
+  font-size: 14px;
+  font-weight: 600;
+  margin-bottom: 10px;
+  color: var(--color-text-secondary);
+}
+
+.empty {
+  margin-top: 16px;
+  font-size: 13px;
+  color: var(--color-text-secondary);
+}
+
+.list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.item {
+  padding: 12px;
+  background: #fafafa;
+  border-radius: 8px;
+  border: 1px solid var(--color-border);
+}
+
+.itemInfo {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  font-size: 13px;
+}
+
+.status {
+  font-weight: 500;
+}
+
+.platforms {
+  display: flex;
+  gap: 4px;
+}
+
+.platformTag {
+  display: inline-block;
+  background: #eee;
+  padding: 1px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+  text-transform: capitalize;
+}
+
+.time {
+  color: var(--color-text-secondary);
+  font-size: 12px;
+  margin-left: auto;
+}
+
+.downloads {
+  display: flex;
+  gap: 8px;
+  margin-top: 8px;
+  flex-wrap: wrap;
+}
+
+.downloadLink {
+  font-size: 12px;
+  color: var(--color-primary);
+  border: 1px solid var(--color-primary);
+  padding: 3px 10px;
+  border-radius: 14px;
+  text-transform: capitalize;
+  transition: all 0.2s;
+}
+
+.downloadLink:hover {
+  background: var(--color-primary);
+  color: white;
+}
+
+.downloadAll {
+  font-size: 12px;
+  color: white;
+  background: var(--color-primary);
+  padding: 3px 10px;
+  border-radius: 14px;
+}
+
+.errorLog {
+  font-size: 12px;
+  color: var(--color-error);
+  margin-top: 4px;
+}
+
+.downloadAll:hover {
+  background: var(--color-primary-hover);
+}

+ 102 - 0
platform/client/src/components/BuildHistory.tsx

@@ -0,0 +1,102 @@
+import { useEffect, useState } from "react";
+import { api } from "../api/client";
+import type { BuildSummary } from "../types";
+import styles from "./BuildHistory.module.css";
+
+interface Props {
+  creativeId: string;
+  builds: BuildSummary[];
+  onUpdated: () => void;
+}
+
+export default function BuildHistory({ creativeId, builds: initialBuilds, onUpdated }: Props) {
+  const [builds, setBuilds] = useState<BuildSummary[]>(initialBuilds);
+
+  useEffect(() => {
+    setBuilds(initialBuilds);
+  }, [initialBuilds]);
+
+  useEffect(() => {
+    // 如果有 building/pending 状态的构建,自动刷新
+    const hasActive = builds.some((b) => b.status === "building" || b.status === "pending");
+    if (!hasActive) return;
+
+    const timer = setInterval(() => {
+      api.getBuilds(creativeId).then((res) => {
+        setBuilds(res.data);
+        onUpdated();
+      });
+    }, 5000);
+
+    return () => clearInterval(timer);
+  }, [builds, creativeId, onUpdated]);
+
+  const statusLabel: Record<string, string> = {
+    pending: "等待中",
+    building: "构建中",
+    completed: "完成",
+    failed: "失败",
+  };
+
+  const statusEmoji: Record<string, string> = {
+    pending: "⏳",
+    building: "🔄",
+    completed: "✅",
+    failed: "❌",
+  };
+
+  if (builds.length === 0) {
+    return <p className={styles.empty}>暂无构建记录</p>;
+  }
+
+  return (
+    <div className={styles.wrapper}>
+      <h3 className={styles.title}>构建历史</h3>
+      <div className={styles.list}>
+        {builds.map((b) => (
+          <div key={b.id} className={styles.item}>
+            <div className={styles.itemInfo}>
+              <span className={styles.status}>
+                {statusEmoji[b.status]} {statusLabel[b.status]}
+              </span>
+              <span className={styles.platforms}>
+                {b.platforms.map((p) => (
+                  <span key={p} className={styles.platformTag}>{p}</span>
+                ))}
+              </span>
+              <span className={styles.time}>
+                {new Date(b.createdAt).toLocaleString("zh-CN")}
+              </span>
+            </div>
+
+            {b.status === "completed" && b.results && (
+              <div className={styles.downloads}>
+                {b.results.map((r) => (
+                  <a
+                    key={r.platform}
+                    href={`/api/v1/builds/${b.id}/download/${r.platform}`}
+                    className={styles.downloadLink}
+                    download
+                  >
+                    {r.platform} ↓ ({(r.fileSize / 1024).toFixed(0)} KB)
+                  </a>
+                ))}
+                <a
+                  href={`/api/v1/builds/${b.id}/download/all`}
+                  className={styles.downloadAll}
+                  download
+                >
+                  全部 ZIP ↓
+                </a>
+              </div>
+            )}
+
+            {b.status === "failed" && b.errorLog && (
+              <p className={styles.errorLog}>{b.errorLog}</p>
+            )}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 58 - 0
platform/client/src/components/BuildPanel.module.css

@@ -0,0 +1,58 @@
+.wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  align-items: flex-start;
+}
+
+.buildBtn {
+  padding: 10px 28px;
+  border: none;
+  border-radius: 8px;
+  background: var(--color-primary);
+  color: white;
+  font-size: 15px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  transition: background 0.2s;
+}
+
+.buildBtn:hover:not(:disabled) {
+  background: var(--color-primary-hover);
+}
+
+.buildBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.spinner {
+  width: 16px;
+  height: 16px;
+  border: 2px solid rgba(255, 255, 255, 0.3);
+  border-top-color: white;
+  border-radius: 50%;
+  animation: spin 0.6s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.hint {
+  font-size: 13px;
+  color: var(--color-text-secondary);
+}
+
+.error {
+  font-size: 13px;
+  color: var(--color-error);
+}
+
+.success {
+  font-size: 13px;
+  color: var(--color-success);
+  font-weight: 500;
+}

+ 117 - 0
platform/client/src/components/BuildPanel.tsx

@@ -0,0 +1,117 @@
+import { useState } from "react";
+import { api } from "../api/client";
+import type { ThemeProp } from "../types";
+import styles from "./BuildPanel.module.css";
+
+interface Props {
+  creativeId: string;
+  creativeStatus: string;
+  selectedPlatforms: string[];
+  theme: Record<string, string>;
+  themeProps: ThemeProp[];
+  onBuildComplete: () => void;
+}
+
+export default function BuildPanel({
+  creativeId,
+  creativeStatus,
+  selectedPlatforms,
+  theme,
+  themeProps,
+  onBuildComplete,
+}: Props) {
+  const [building, setBuilding] = useState(false);
+  const [buildId, setBuildId] = useState<string | null>(null);
+  const [buildError, setBuildError] = useState("");
+
+  const canBuild = creativeStatus === "assets_ready" || creativeStatus === "built";
+
+  async function handleBuild() {
+    if (selectedPlatforms.length === 0) {
+      setBuildError("请至少选择一个目标平台");
+      return;
+    }
+
+    setBuilding(true);
+    setBuildError("");
+    setBuildId(null);
+
+    try {
+      // 合并 theme:用 themeProps 的 default 补全缺失值
+      const mergedTheme: Record<string, string> = {};
+      for (const prop of themeProps) {
+        mergedTheme[prop.key] = theme[prop.key] ?? prop.default;
+      }
+
+      const res = await api.triggerBuild(creativeId, {
+        platforms: selectedPlatforms,
+        theme: mergedTheme,
+      });
+
+      const bid = res.data.id;
+      setBuildId(bid);
+
+      // 轮询构建状态
+      await pollBuildStatus(bid);
+      onBuildComplete();
+    } catch (err: any) {
+      setBuildError(err.message);
+    } finally {
+      setBuilding(false);
+    }
+  }
+
+  async function pollBuildStatus(bid: string): Promise<void> {
+    return new Promise((resolve, reject) => {
+      const interval = setInterval(async () => {
+        try {
+          const res = await api.getBuildStatus(bid);
+          if (res.data.status === "completed") {
+            clearInterval(interval);
+            resolve();
+          } else if (res.data.status === "failed") {
+            clearInterval(interval);
+            reject(new Error(res.data.errorLog || "构建失败"));
+          }
+        } catch (err: any) {
+          clearInterval(interval);
+          reject(err);
+        }
+      }, 2000);
+
+      // 超时 120 秒
+      setTimeout(() => {
+        clearInterval(interval);
+        reject(new Error("构建超时,请刷新查看状态"));
+      }, 120_000);
+    });
+  }
+
+  return (
+    <div className={styles.wrapper}>
+      <button
+        onClick={handleBuild}
+        disabled={!canBuild || building}
+        className={styles.buildBtn}
+      >
+        {building ? (
+          <>
+            <span className={styles.spinner} /> 构建中…
+          </>
+        ) : (
+          "🚀 开始构建"
+        )}
+      </button>
+
+      {!canBuild && (
+        <p className={styles.hint}>请先上传素材后再构建</p>
+      )}
+
+      {buildError && <p className={styles.error}>构建失败:{buildError}</p>}
+
+      {buildId && !building && !buildError && (
+        <p className={styles.success}>构建完成!请在下方下载产物。</p>
+      )}
+    </div>
+  );
+}

+ 72 - 0
platform/client/src/components/GradientEditor.module.css

@@ -0,0 +1,72 @@
+.wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.preview {
+  height: 32px;
+  border-radius: 6px;
+  border: 1px solid var(--color-border);
+  cursor: default;
+}
+
+.stops {
+  display: flex;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.stopItem {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.colorPicker {
+  width: 28px;
+  height: 28px;
+  border: 1px solid var(--color-border);
+  border-radius: 4px;
+  padding: 0;
+  cursor: pointer;
+  background: none;
+}
+
+.colorPicker::-webkit-color-swatch-wrapper {
+  padding: 2px;
+}
+
+.colorPicker::-webkit-color-swatch {
+  border: none;
+  border-radius: 2px;
+}
+
+.stopLabel {
+  font-family: monospace;
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+
+.rawInput {
+  padding: 6px 10px;
+  border: 1px solid var(--color-border);
+  border-radius: 6px;
+  font-size: 12px;
+  font-family: monospace;
+  outline: none;
+  color: var(--color-text-secondary);
+}
+
+.rawInput:focus {
+  border-color: var(--color-primary);
+}
+
+.fallback {
+  width: 100%;
+  padding: 8px 12px;
+  border: 1px solid var(--color-border);
+  border-radius: 6px;
+  font-size: 13px;
+  outline: none;
+}

+ 108 - 0
platform/client/src/components/GradientEditor.tsx

@@ -0,0 +1,108 @@
+import { useMemo } from "react";
+import styles from "./GradientEditor.module.css";
+
+interface ColorStop {
+  color: string;
+  position: string;
+}
+
+interface Props {
+  value: string;
+  onChange: (value: string) => void;
+}
+
+/**
+ * 解析 CSS linear-gradient 字符串,提取方向和色标。
+ * 例如: "linear-gradient(160deg, #fff9f2 0%, #ffeedd 100%)"
+ */
+function parseGradient(gradient: string): { direction: string; stops: ColorStop[] } | null {
+  const match = gradient.match(/linear-gradient\(([^,]+),\s*(.+)\)/);
+  if (!match) return null;
+
+  const direction = match[1].trim();
+  const stopsStr = match[2];
+
+  // 分割色标: "#fff9f2 0%, #ffeedd 100%"
+  const stops: ColorStop[] = [];
+  const parts = stopsStr.split(/,(?![^(]*\))/); // split on commas not inside parens
+  for (const part of parts) {
+    const trimmed = part.trim();
+    // 匹配: <color> <position>
+    const m = trimmed.match(/^(.+?)\s+(\d+%)$/);
+    if (m) {
+      stops.push({ color: m[1].trim(), position: m[2] });
+    } else {
+      // 可能只有颜色, 没有位置
+      stops.push({ color: trimmed, position: "" });
+    }
+  }
+
+  return { direction, stops };
+}
+
+function buildGradient(direction: string, stops: ColorStop[]): string {
+  const stopsStr = stops
+    .map((s) => (s.position ? `${s.color} ${s.position}` : s.color))
+    .join(", ");
+  return `linear-gradient(${direction}, ${stopsStr})`;
+}
+
+export default function GradientEditor({ value, onChange }: Props) {
+  const parsed = useMemo(() => parseGradient(value), [value]);
+
+  if (!parsed) {
+    // 无法解析时退化为纯文本输入
+    return (
+      <input
+        type="text"
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        className={styles.fallback}
+      />
+    );
+  }
+
+  function updateStopColor(index: number, color: string) {
+    const newStops = parsed!.stops.map((s, i) =>
+      i === index ? { ...s, color } : s
+    );
+    onChange(buildGradient(parsed!.direction, newStops));
+  }
+
+  return (
+    <div className={styles.wrapper}>
+      {/* 渐变色预览条 */}
+      <div
+        className={styles.preview}
+        style={{ background: value }}
+        title={value}
+      />
+
+      {/* 各色标取色器 */}
+      <div className={styles.stops}>
+        {parsed.stops.map((stop, i) => (
+          <div key={i} className={styles.stopItem}>
+            <input
+              type="color"
+              value={stop.color}
+              onChange={(e) => updateStopColor(i, e.target.value)}
+              className={styles.colorPicker}
+            />
+            <span className={styles.stopLabel}>
+              {stop.color}
+              {stop.position && ` ${stop.position}`}
+            </span>
+          </div>
+        ))}
+      </div>
+
+      {/* 原始值(可手动编辑) */}
+      <input
+        type="text"
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        className={styles.rawInput}
+      />
+    </div>
+  );
+}

+ 54 - 0
platform/client/src/components/Layout.module.css

@@ -0,0 +1,54 @@
+.wrapper {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.header {
+  background: var(--color-surface);
+  border-bottom: 1px solid var(--color-border);
+  padding: 0 24px;
+  height: 56px;
+  display: flex;
+  align-items: center;
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+
+.headerInner {
+  max-width: 1200px;
+  width: 100%;
+  margin: 0 auto;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.logo {
+  font-size: 18px;
+  font-weight: 700;
+  color: var(--color-text);
+}
+
+.newBtn {
+  background: var(--color-primary);
+  color: white;
+  padding: 8px 20px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  transition: background 0.2s;
+}
+
+.newBtn:hover {
+  background: var(--color-primary-hover);
+}
+
+.main {
+  flex: 1;
+  max-width: 1200px;
+  width: 100%;
+  margin: 0 auto;
+  padding: 24px;
+}

+ 26 - 0
platform/client/src/components/Layout.tsx

@@ -0,0 +1,26 @@
+import { ReactNode } from "react";
+import { Link, useLocation } from "react-router-dom";
+import styles from "./Layout.module.css";
+
+export default function Layout({ children }: { children: ReactNode }) {
+  const location = useLocation();
+  const isHome = location.pathname === "/";
+
+  return (
+    <div className={styles.wrapper}>
+      <header className={styles.header}>
+        <div className={styles.headerInner}>
+          <Link to="/" className={styles.logo}>
+            Playable Ads
+          </Link>
+          {isHome && (
+            <Link to="/creatives/new" className={styles.newBtn}>
+              + 新建创意
+            </Link>
+          )}
+        </div>
+      </header>
+      <main className={styles.main}>{children}</main>
+    </div>
+  );
+}

+ 37 - 0
platform/client/src/components/PlatformSelector.module.css

@@ -0,0 +1,37 @@
+.wrapper {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 16px;
+}
+
+.label {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--color-text-secondary);
+  margin-right: 4px;
+}
+
+.chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 6px 14px;
+  border: 1px solid var(--color-border);
+  border-radius: 20px;
+  font-size: 13px;
+  cursor: pointer;
+  user-select: none;
+  transition: all 0.2s;
+  text-transform: capitalize;
+}
+
+.chip.on {
+  background: var(--color-primary);
+  color: white;
+  border-color: var(--color-primary);
+}
+
+.checkbox {
+  display: none;
+}

+ 39 - 0
platform/client/src/components/PlatformSelector.tsx

@@ -0,0 +1,39 @@
+import styles from "./PlatformSelector.module.css";
+
+interface Props {
+  platforms: string[];
+  selected: string[];
+  onChange: (selected: string[]) => void;
+}
+
+export default function PlatformSelector({ platforms, selected, onChange }: Props) {
+  const selectedSet = new Set(selected);
+
+  function toggle(p: string) {
+    if (selectedSet.has(p)) {
+      onChange(selected.filter((s) => s !== p));
+    } else {
+      onChange([...selected, p]);
+    }
+  }
+
+  return (
+    <div className={styles.wrapper}>
+      <span className={styles.label}>目标平台:</span>
+      {platforms.map((p) => (
+        <label
+          key={p}
+          className={`${styles.chip} ${selectedSet.has(p) ? styles.on : ""}`}
+        >
+          <input
+            type="checkbox"
+            checked={selectedSet.has(p)}
+            onChange={() => toggle(p)}
+            className={styles.checkbox}
+          />
+          {p}
+        </label>
+      ))}
+    </div>
+  );
+}

+ 96 - 0
platform/client/src/components/PreviewPanel.module.css

@@ -0,0 +1,96 @@
+.wrapper {
+  background: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius);
+  padding: 16px;
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.title {
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.startBtn {
+  padding: 6px 16px;
+  border: none;
+  border-radius: 6px;
+  background: var(--color-success);
+  color: white;
+  font-size: 13px;
+  font-weight: 500;
+}
+
+.startBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.stopBtn {
+  padding: 6px 16px;
+  border: 1px solid var(--color-error);
+  border-radius: 6px;
+  background: white;
+  color: var(--color-error);
+  font-size: 13px;
+}
+
+.hint {
+  font-size: 13px;
+  color: var(--color-text-secondary);
+  text-align: center;
+  padding: 16px;
+}
+
+.error {
+  font-size: 13px;
+  color: var(--color-error);
+}
+
+.loadingBox {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  padding: 40px 20px;
+  font-size: 13px;
+  color: var(--color-text-secondary);
+  background: #fafafa;
+  border-radius: 8px;
+}
+
+.spinner {
+  width: 18px;
+  height: 18px;
+  border: 2px solid var(--color-border);
+  border-top-color: var(--color-primary);
+  border-radius: 50%;
+  animation: spin 0.6s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.frameWrap {
+  border: 1px solid var(--color-border);
+  border-radius: 8px;
+  overflow: hidden;
+  background: white;
+  /* 9:16 portrait aspect ratio typical for mobile ads */
+  aspect-ratio: 9 / 16;
+  max-height: 640px;
+  margin: 0 auto;
+}
+
+.frame {
+  width: 100%;
+  height: 100%;
+  border: none;
+}

+ 105 - 0
platform/client/src/components/PreviewPanel.tsx

@@ -0,0 +1,105 @@
+import { useState, useEffect } from "react";
+import { api } from "../api/client";
+import styles from "./PreviewPanel.module.css";
+
+interface Props {
+  creativeId: string;
+  creativeStatus: string;
+  theme: Record<string, string>;
+}
+
+export default function PreviewPanel({ creativeId, creativeStatus, theme }: Props) {
+  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState("");
+  const [loaded, setLoaded] = useState(false);
+
+  const canPreview = creativeStatus === "assets_ready" || creativeStatus === "built" || creativeStatus === "building";
+
+  // 预览启动后 5s 超时
+  useEffect(() => {
+    if (!previewUrl || loaded) return;
+    const timer = setTimeout(() => {
+      if (!loaded) setError("预览加载超时,请检查控制台或重试");
+    }, 10000);
+    return () => clearTimeout(timer);
+  }, [previewUrl, loaded]);
+
+  // 退出页面时停止预览
+  useEffect(() => {
+    return () => {
+      if (previewUrl) {
+        api.stopPreview(creativeId).catch(() => {});
+      }
+    };
+  }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+  async function handleStart() {
+    setLoading(true);
+    setError("");
+    setLoaded(false);
+    try {
+      const res = await api.startPreview(creativeId, theme);
+      setPreviewUrl(res.data.url);
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function handleStop() {
+    try {
+      await api.stopPreview(creativeId);
+    } catch {}
+    setPreviewUrl(null);
+    setLoaded(false);
+    setError("");
+  }
+
+  return (
+    <div className={styles.wrapper}>
+      <div className={styles.header}>
+        <h3 className={styles.title}>实时预览</h3>
+        {!previewUrl ? (
+          <button
+            onClick={handleStart}
+            disabled={!canPreview || loading}
+            className={styles.startBtn}
+          >
+            {loading ? "启动中…" : "▶ 开始预览"}
+          </button>
+        ) : (
+          <button onClick={handleStop} className={styles.stopBtn}>
+            关闭预览
+          </button>
+        )}
+      </div>
+
+      {!canPreview && !previewUrl && (
+        <p className={styles.hint}>上传素材后即可预览</p>
+      )}
+
+      {error && <p className={styles.error}>{error}</p>}
+
+      {loading && (
+        <div className={styles.loadingBox}>
+          <span className={styles.spinner} />
+          <span>Vite 开发服务器启动中,请稍候…</span>
+        </div>
+      )}
+
+      {previewUrl && (
+        <div className={styles.frameWrap}>
+          <iframe
+            src={previewUrl}
+            className={styles.frame}
+            title="广告预览"
+            onLoad={() => setLoaded(true)}
+            sandbox="allow-scripts allow-same-origin"
+          />
+        </div>
+      )}
+    </div>
+  );
+}

+ 77 - 0
platform/client/src/components/ThemeEditor.module.css

@@ -0,0 +1,77 @@
+.wrapper {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.empty {
+  color: var(--color-text-secondary);
+  font-size: 14px;
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.label {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--color-text-secondary);
+}
+
+.textInput {
+  padding: 8px 12px;
+  border: 1px solid var(--color-border);
+  border-radius: 6px;
+  font-size: 14px;
+  outline: none;
+}
+
+.textInput:focus {
+  border-color: var(--color-primary);
+}
+
+.colorRow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.colorInput {
+  width: 36px;
+  height: 36px;
+  border: 1px solid var(--color-border);
+  border-radius: 6px;
+  padding: 2px;
+  cursor: pointer;
+}
+
+.colorValue {
+  font-family: monospace;
+  font-size: 13px;
+  color: var(--color-text-secondary);
+}
+
+.saveBtn {
+  padding: 8px 16px;
+  border: 1px solid var(--color-primary);
+  border-radius: 6px;
+  background: white;
+  color: var(--color-primary);
+  font-size: 13px;
+  font-weight: 500;
+  align-self: flex-start;
+  transition: background 0.2s;
+}
+
+.saveBtn:hover:not(:disabled) {
+  background: var(--color-primary);
+  color: white;
+}
+
+.saveBtn:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}

+ 111 - 0
platform/client/src/components/ThemeEditor.tsx

@@ -0,0 +1,111 @@
+import { useState, useEffect } from "react";
+import { api } from "../api/client";
+import type { ThemeProp } from "../types";
+import GradientEditor from "./GradientEditor";
+import styles from "./ThemeEditor.module.css";
+
+interface Props {
+  creativeId: string;
+  theme: Record<string, string>;
+  themeProps: ThemeProp[];
+  onUpdated: () => void;
+}
+
+export default function ThemeEditor({ creativeId, theme, themeProps, onUpdated }: Props) {
+  const [values, setValues] = useState<Record<string, string>>({});
+  const [saving, setSaving] = useState(false);
+  const [saved, setSaved] = useState(false);
+
+  // 初始化:用当前 theme 或 default 填充
+  useEffect(() => {
+    const init: Record<string, string> = {};
+    for (const prop of themeProps) {
+      init[prop.key] = theme[prop.key] ?? prop.default;
+    }
+    setValues(init);
+  }, [theme, themeProps]);
+
+  if (themeProps.length === 0) {
+    return <p className={styles.empty}>此模板无可配置的主题参数。</p>;
+  }
+
+  const changed =
+    JSON.stringify(values) !==
+    JSON.stringify(
+      Object.fromEntries(themeProps.map((p) => [p.key, theme[p.key] ?? p.default]))
+    );
+
+  async function handleSave() {
+    setSaving(true);
+    try {
+      await api.updateCreative(creativeId, { theme: values });
+      setSaved(true);
+      setTimeout(() => setSaved(false), 2000);
+      onUpdated();
+    } catch {
+      // error handling via BuildPanel
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  return (
+    <div className={styles.wrapper}>
+      {themeProps.map((prop) => (
+        <div key={prop.key} className={styles.field}>
+          <label className={styles.label}>{prop.label}</label>
+          {prop.type === "color" ? (
+            <div className={styles.colorRow}>
+              <input
+                type="color"
+                value={values[prop.key] || prop.default}
+                onChange={(e) =>
+                  setValues((v) => ({ ...v, [prop.key]: e.target.value }))
+                }
+                className={styles.colorInput}
+              />
+              <span className={styles.colorValue}>
+                {values[prop.key] || prop.default}
+              </span>
+            </div>
+          ) : prop.type === "text" ? (
+            <input
+              type="text"
+              value={values[prop.key] || ""}
+              maxLength={prop.maxLength}
+              onChange={(e) =>
+                setValues((v) => ({ ...v, [prop.key]: e.target.value }))
+              }
+              className={styles.textInput}
+            />
+          ) : prop.type === "css-gradient" ? (
+            <GradientEditor
+              value={values[prop.key] || prop.default}
+              onChange={(newVal) =>
+                setValues((v) => ({ ...v, [prop.key]: newVal }))
+              }
+            />
+          ) : (
+            <input
+              type="text"
+              value={values[prop.key] || prop.default}
+              onChange={(e) =>
+                setValues((v) => ({ ...v, [prop.key]: e.target.value }))
+              }
+              className={styles.textInput}
+              placeholder={prop.default}
+            />
+          )}
+        </div>
+      ))}
+
+      <button
+        onClick={handleSave}
+        disabled={!changed || saving}
+        className={styles.saveBtn}
+      >
+        {saving ? "保存中…" : saved ? "已保存 ✓" : "保存主题"}
+      </button>
+    </div>
+  );
+}

+ 38 - 0
platform/client/src/index.css

@@ -0,0 +1,38 @@
+:root {
+  --color-bg: #f5f5f7;
+  --color-surface: #ffffff;
+  --color-border: #e5e5e7;
+  --color-text: #1d1d1f;
+  --color-text-secondary: #86868b;
+  --color-primary: #0071e3;
+  --color-primary-hover: #0077ed;
+  --color-success: #34c759;
+  --color-warning: #ff9500;
+  --color-error: #ff3b30;
+  --radius: 12px;
+  --shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+}
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+  background: var(--color-bg);
+  color: var(--color-text);
+  line-height: 1.6;
+  -webkit-font-smoothing: antialiased;
+}
+
+a {
+  color: var(--color-primary);
+  text-decoration: none;
+}
+
+button {
+  cursor: pointer;
+  font-family: inherit;
+}

+ 13 - 0
platform/client/src/main.tsx

@@ -0,0 +1,13 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+  <React.StrictMode>
+    <BrowserRouter>
+      <App />
+    </BrowserRouter>
+  </React.StrictMode>
+);

+ 93 - 0
platform/client/src/pages/CreativeDetail.module.css

@@ -0,0 +1,93 @@
+.container {
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+
+.state {
+  text-align: center;
+  padding: 60px 20px;
+  color: var(--color-text-secondary);
+}
+
+/* 顶部栏 */
+.topBar {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.back {
+  background: none;
+  border: none;
+  color: var(--color-primary);
+  font-size: 14px;
+  padding: 0;
+}
+
+.topInfo {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex: 1;
+}
+
+.title {
+  font-size: 22px;
+  font-weight: 700;
+}
+
+.statusDot {
+  font-size: 13px;
+  font-weight: 500;
+}
+
+.statusDot.draft { color: #86868b; }
+.statusDot.assets_ready { color: #0071e3; }
+.statusDot.building { color: #ff9500; }
+.statusDot.built { color: #34c759; }
+.statusDot.failed { color: #ff3b30; }
+
+.deleteBtn {
+  background: none;
+  border: 1px solid var(--color-error);
+  color: var(--color-error);
+  padding: 6px 14px;
+  border-radius: 6px;
+  font-size: 13px;
+}
+
+/* 工作区域 */
+.workspace {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 20px;
+}
+
+@media (max-width: 768px) {
+  .workspace {
+    grid-template-columns: 1fr;
+  }
+}
+
+.section {
+  background: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius);
+  padding: 20px;
+}
+
+.sectionTitle {
+  font-size: 15px;
+  font-weight: 600;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid var(--color-border);
+}
+
+.buildSection {
+  background: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius);
+  padding: 20px;
+}

+ 146 - 0
platform/client/src/pages/CreativeDetail.tsx

@@ -0,0 +1,146 @@
+import { useEffect, useState, useCallback } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { api } from "../api/client";
+import type { Creative, TemplateDetail } from "../types";
+import AssetUploader from "../components/AssetUploader";
+import ThemeEditor from "../components/ThemeEditor";
+import PlatformSelector from "../components/PlatformSelector";
+import BuildPanel from "../components/BuildPanel";
+import BuildHistory from "../components/BuildHistory";
+import PreviewPanel from "../components/PreviewPanel";
+import styles from "./CreativeDetail.module.css";
+
+export default function CreativeDetail() {
+  const { id } = useParams<{ id: string }>();
+  const navigate = useNavigate();
+
+  const [creative, setCreative] = useState<Creative | null>(null);
+  const [template, setTemplate] = useState<TemplateDetail | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState("");
+  const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([
+    "google",
+    "applovin",
+  ]);
+
+  const fetchCreative = useCallback(() => {
+    if (!id) return;
+    setError("");
+    api
+      .getCreative(id)
+      .then(async (res) => {
+        setCreative(res.data);
+        const tplRes = await api.getTemplate(res.data.templateId);
+        setTemplate(tplRes.data);
+        // 初始化平台选择
+        if (selectedPlatforms.length === 0 && tplRes.data.platforms?.available) {
+          setSelectedPlatforms(
+            tplRes.data.platforms?.defaults ?? tplRes.data.platforms.available.slice(0, 2)
+          );
+        }
+      })
+      .catch((e) => setError(e.message))
+      .finally(() => setLoading(false));
+  }, [id]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  useEffect(() => {
+    fetchCreative();
+  }, [fetchCreative]);
+
+  async function handleDelete() {
+    if (!id || !confirm("确认删除此创意?所有素材和构建产物将被永久删除。")) return;
+    try {
+      await api.deleteCreative(id);
+      navigate("/");
+    } catch (err: any) {
+      setError(err.message);
+    }
+  }
+
+  const statusLabel: Record<string, string> = {
+    draft: "草稿",
+    assets_ready: "素材已就绪",
+    building: "构建中",
+    built: "已构建",
+    failed: "失败",
+  };
+
+  if (loading) return <div className={styles.state}>加载中…</div>;
+  if (error) return <div className={styles.state}>加载失败:{error}</div>;
+  if (!creative || !template) return <div className={styles.state}>创意不存在</div>;
+
+  return (
+    <div className={styles.container}>
+      {/* 顶部栏 */}
+      <div className={styles.topBar}>
+        <button onClick={() => navigate(-1)} className={styles.back}>
+          ← 返回
+        </button>
+        <div className={styles.topInfo}>
+          <h1 className={styles.title}>{creative.name}</h1>
+          <span className={`${styles.statusDot} ${styles[creative.status]}`}>
+            ● {statusLabel[creative.status]}
+          </span>
+        </div>
+        <button onClick={handleDelete} className={styles.deleteBtn}>
+          删除
+        </button>
+      </div>
+
+      {/* 工作区域 */}
+      <div className={styles.workspace}>
+        {/* 左栏:素材上传 */}
+        <section className={styles.section}>
+          <h2 className={styles.sectionTitle}>素材</h2>
+          <AssetUploader
+            creativeId={creative.id}
+            assets={creative.assets ?? []}
+            assetDefs={template.assets}
+            onUpdated={fetchCreative}
+          />
+        </section>
+
+        {/* 右栏:主题配置 */}
+        <section className={styles.section}>
+          <h2 className={styles.sectionTitle}>主题配置</h2>
+          <ThemeEditor
+            creativeId={creative.id}
+            theme={creative.theme ?? {}}
+            themeProps={template.theme.properties}
+            onUpdated={fetchCreative}
+          />
+        </section>
+      </div>
+
+      {/* 预览 */}
+      <PreviewPanel
+        creativeId={creative.id}
+        creativeStatus={creative.status}
+        theme={creative.theme ?? {}}
+      />
+
+      {/* 底部:构建 */}
+      <section className={styles.buildSection}>
+        <h2 className={styles.sectionTitle}>构建</h2>
+        <PlatformSelector
+          platforms={template.platforms?.available ?? []}
+          selected={selectedPlatforms}
+          onChange={setSelectedPlatforms}
+        />
+        <BuildPanel
+          creativeId={creative.id}
+          creativeStatus={creative.status}
+          selectedPlatforms={selectedPlatforms}
+          theme={creative.theme ?? {}}
+          themeProps={template.theme.properties}
+          onBuildComplete={fetchCreative}
+        />
+        <BuildHistory
+          creativeId={creative.id}
+          builds={creative.recentBuilds ?? []}
+          onUpdated={fetchCreative}
+        />
+      </section>
+    </div>
+  );
+}

+ 73 - 0
platform/client/src/pages/Dashboard.module.css

@@ -0,0 +1,73 @@
+.grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+  gap: 16px;
+}
+
+.card {
+  background: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius);
+  padding: 20px;
+  transition: box-shadow 0.2s;
+  display: block;
+  color: inherit;
+}
+
+.card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.cardTitle {
+  font-size: 16px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.cardMeta {
+  display: flex;
+  justify-content: space-between;
+  font-size: 13px;
+  color: var(--color-text-secondary);
+  margin-bottom: 8px;
+}
+
+.status {
+  font-weight: 500;
+}
+
+.cardTime {
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+
+.state {
+  text-align: center;
+  padding: 60px 20px;
+  color: var(--color-text-secondary);
+}
+
+.empty {
+  text-align: center;
+  padding: 80px 20px;
+}
+
+.emptyTitle {
+  font-size: 20px;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.emptyDesc {
+  color: var(--color-text-secondary);
+  margin-bottom: 24px;
+}
+
+.emptyBtn {
+  display: inline-block;
+  background: var(--color-primary);
+  color: white;
+  padding: 10px 24px;
+  border-radius: 8px;
+  font-weight: 500;
+}

+ 77 - 0
platform/client/src/pages/Dashboard.tsx

@@ -0,0 +1,77 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { api } from "../api/client";
+import type { Creative } from "../types";
+import styles from "./Dashboard.module.css";
+
+export default function Dashboard() {
+  const [creatives, setCreatives] = useState<Creative[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState("");
+
+  useEffect(() => {
+    api
+      .getCreatives()
+      .then((res) => setCreatives(res.data))
+      .catch((e) => setError(e.message))
+      .finally(() => setLoading(false));
+  }, []);
+
+  const statusLabel: Record<string, string> = {
+    draft: "草稿",
+    assets_ready: "素材已就绪",
+    building: "构建中",
+    built: "已构建",
+    failed: "失败",
+  };
+
+  const statusColor: Record<string, string> = {
+    draft: "#86868b",
+    assets_ready: "#0071e3",
+    building: "#ff9500",
+    built: "#34c759",
+    failed: "#ff3b30",
+  };
+
+  if (loading) {
+    return <div className={styles.state}>加载中…</div>;
+  }
+
+  if (error) {
+    return <div className={styles.state}>加载失败:{error}</div>;
+  }
+
+  if (creatives.length === 0) {
+    return (
+      <div className={styles.empty}>
+        <p className={styles.emptyTitle}>暂无创意</p>
+        <p className={styles.emptyDesc}>点击"新建创意"开始制作 Playable 广告</p>
+        <Link to="/creatives/new" className={styles.emptyBtn}>
+          新建创意
+        </Link>
+      </div>
+    );
+  }
+
+  return (
+    <div className={styles.grid}>
+      {creatives.map((c) => (
+        <Link to={`/creatives/${c.id}`} key={c.id} className={styles.card}>
+          <h3 className={styles.cardTitle}>{c.name}</h3>
+          <div className={styles.cardMeta}>
+            <span>模板:{c.templateName}</span>
+            <span
+              className={styles.status}
+              style={{ color: statusColor[c.status] }}
+            >
+              ● {statusLabel[c.status] || c.status}
+            </span>
+          </div>
+          <div className={styles.cardTime}>
+            更新于 {new Date(c.updatedAt).toLocaleString("zh-CN")}
+          </div>
+        </Link>
+      ))}
+    </div>
+  );
+}

+ 125 - 0
platform/client/src/pages/NewCreative.module.css

@@ -0,0 +1,125 @@
+.container {
+  max-width: 640px;
+}
+
+.back {
+  background: none;
+  border: none;
+  color: var(--color-primary);
+  font-size: 14px;
+  margin-bottom: 16px;
+  padding: 0;
+}
+
+.title {
+  font-size: 24px;
+  font-weight: 700;
+  margin-bottom: 32px;
+}
+
+.form {
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+
+.section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.label {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--color-text);
+}
+
+.input {
+  padding: 10px 14px;
+  border: 1px solid var(--color-border);
+  border-radius: 8px;
+  font-size: 15px;
+  outline: none;
+  transition: border-color 0.2s;
+}
+
+.input:focus {
+  border-color: var(--color-primary);
+}
+
+.templateList {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.templateCard {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  padding: 14px;
+  border: 2px solid var(--color-border);
+  border-radius: var(--radius);
+  cursor: pointer;
+  transition: border-color 0.2s;
+}
+
+.templateCard.selected {
+  border-color: var(--color-primary);
+  background: rgba(0, 113, 227, 0.04);
+}
+
+.templateCard input {
+  margin-top: 2px;
+}
+
+.templateInfo {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.templateDesc {
+  font-size: 13px;
+  color: var(--color-text-secondary);
+}
+
+.templateMeta {
+  font-size: 12px;
+  color: var(--color-text-secondary);
+}
+
+.error {
+  color: var(--color-error);
+  font-size: 14px;
+}
+
+.actions {
+  display: flex;
+  gap: 12px;
+  justify-content: flex-end;
+}
+
+.cancelBtn {
+  padding: 10px 24px;
+  border: 1px solid var(--color-border);
+  border-radius: 8px;
+  background: white;
+  font-size: 14px;
+}
+
+.submitBtn {
+  padding: 10px 24px;
+  border: none;
+  border-radius: 8px;
+  background: var(--color-primary);
+  color: white;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.submitBtn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}

+ 121 - 0
platform/client/src/pages/NewCreative.tsx

@@ -0,0 +1,121 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { api } from "../api/client";
+import type { Template } from "../types";
+import styles from "./NewCreative.module.css";
+
+export default function NewCreative() {
+  const navigate = useNavigate();
+  const [templates, setTemplates] = useState<Template[]>([]);
+  const [selectedTemplate, setSelectedTemplate] = useState<string>("coloring");
+  const [name, setName] = useState("");
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState("");
+
+  useEffect(() => {
+    api
+      .getTemplates()
+      .then((res) => {
+        setTemplates(res.data);
+        if (res.data.length > 0) setSelectedTemplate(res.data[0].id);
+      })
+      .catch((e) => setError(e.message));
+  }, []);
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    if (!name.trim()) return;
+
+    setLoading(true);
+    setError("");
+
+    try {
+      const res = await api.createCreative({
+        name: name.trim(),
+        templateId: selectedTemplate,
+      });
+      navigate(`/creatives/${res.data.id}`);
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  return (
+    <div className={styles.container}>
+      <button onClick={() => navigate(-1)} className={styles.back}>
+        ← 返回
+      </button>
+
+      <h2 className={styles.title}>新建创意</h2>
+
+      <form onSubmit={handleSubmit} className={styles.form}>
+        <div className={styles.section}>
+          <label className={styles.label}>选择模板</label>
+          <div className={styles.templateList}>
+            {templates.map((t) => (
+              <label
+                key={t.id}
+                className={`${styles.templateCard} ${
+                  selectedTemplate === t.id ? styles.selected : ""
+                }`}
+              >
+                <input
+                  type="radio"
+                  name="template"
+                  value={t.id}
+                  checked={selectedTemplate === t.id}
+                  onChange={() => setSelectedTemplate(t.id)}
+                />
+                <div className={styles.templateInfo}>
+                  <strong>{t.name}</strong>
+                  <span className={styles.templateDesc}>{t.description}</span>
+                  <span className={styles.templateMeta}>
+                    必填素材 {t.assetCount.required} 项 · 可选 {t.assetCount.optional} 项 ·{" "}
+                    支持 {t.platforms.length} 个平台
+                  </span>
+                </div>
+              </label>
+            ))}
+          </div>
+        </div>
+
+        <div className={styles.section}>
+          <label className={styles.label} htmlFor="name">
+            创意名称
+          </label>
+          <input
+            id="name"
+            type="text"
+            className={styles.input}
+            placeholder="例如:萌宠填色-谷歌渠道"
+            value={name}
+            onChange={(e) => setName(e.target.value)}
+            maxLength={100}
+            required
+          />
+        </div>
+
+        {error && <p className={styles.error}>{error}</p>}
+
+        <div className={styles.actions}>
+          <button
+            type="button"
+            className={styles.cancelBtn}
+            onClick={() => navigate(-1)}
+          >
+            取消
+          </button>
+          <button
+            type="submit"
+            className={styles.submitBtn}
+            disabled={!name.trim() || loading}
+          >
+            {loading ? "创建中…" : "创建"}
+          </button>
+        </div>
+      </form>
+    </div>
+  );
+}

+ 81 - 0
platform/client/src/types/index.ts

@@ -0,0 +1,81 @@
+// Template list item (GET /templates)
+export interface Template {
+  id: string;
+  name: string;
+  description: string;
+  version: string;
+  platforms: string[];
+  assetCount: { required: number; optional: number };
+}
+
+// Template detail (GET /templates/:id — manifest fields spread at top level)
+export interface TemplateDetail {
+  id: string;
+  name: string;
+  description: string;
+  version: string;
+  assets: {
+    uploadFormat: string;
+    required: AssetDef[];
+    optional: AssetDef[];
+  };
+  theme: {
+    properties: ThemeProp[];
+  };
+  platforms: {
+    available: string[];
+    defaults?: string[];
+  };
+}
+
+export interface AssetDef {
+  key: string;
+  file: string;
+  label: string;
+  accept: string;
+}
+
+export interface ThemeProp {
+  key: string;
+  label: string;
+  type: "css-gradient" | "text" | "color";
+  default: string;
+  maxLength?: number;
+}
+
+export interface Creative {
+  id: string;
+  name: string;
+  templateId: string;
+  templateName?: string;
+  template?: { id: string; name: string };
+  status: "draft" | "assets_ready" | "building" | "built" | "failed";
+  theme: Record<string, string>;
+  assets?: CreativeAsset[];
+  recentBuilds?: BuildSummary[];
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface CreativeAsset {
+  key: string;
+  fileName: string;
+  fileSize: number;
+  isRequired: boolean;
+}
+
+export interface BuildSummary {
+  id: string;
+  status: string;
+  platforms: string[];
+  results?: BuildResult[];
+  errorLog?: string;
+  startedAt?: string;
+  finishedAt?: string;
+  createdAt: string;
+}
+
+export interface BuildResult {
+  platform: string;
+  fileSize: number;
+}

+ 6 - 0
platform/client/src/vite-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="vite/client" />
+
+declare module "*.module.css" {
+  const classes: { readonly [key: string]: string };
+  export default classes;
+}

+ 21 - 0
platform/client/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "forceConsistentCasingInFileNames": true
+  },
+  "include": ["src"]
+}

+ 15 - 0
platform/client/vite.config.ts

@@ -0,0 +1,15 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    port: 9527,
+    proxy: {
+      "/api": {
+        target: "http://localhost:3001",
+        changeOrigin: true,
+      },
+    },
+  },
+});

+ 3 - 0
platform/server/dist/db/database.d.ts

@@ -0,0 +1,3 @@
+import Database from "better-sqlite3";
+export declare function initDatabase(storageDir: string): Database.Database;
+//# sourceMappingURL=database.d.ts.map

+ 1 - 0
platform/server/dist/db/database.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAItC,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,QAAQ,CAAC,QAAQ,CAgElE"}

+ 71 - 0
platform/server/dist/db/database.js

@@ -0,0 +1,71 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.initDatabase = initDatabase;
+const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
+const path_1 = __importDefault(require("path"));
+const fs_1 = __importDefault(require("fs"));
+function initDatabase(storageDir) {
+    // 确保 storage 目录存在
+    if (!fs_1.default.existsSync(storageDir)) {
+        fs_1.default.mkdirSync(storageDir, { recursive: true });
+    }
+    const dbPath = path_1.default.join(storageDir, "data.db");
+    const db = new better_sqlite3_1.default(dbPath);
+    // WAL 模式提升并发读取
+    db.pragma("journal_mode = WAL");
+    db.pragma("foreign_keys = ON");
+    // 建表
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS templates (
+      id          TEXT PRIMARY KEY,
+      name        TEXT NOT NULL,
+      manifest    TEXT NOT NULL,
+      created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+      updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE TABLE IF NOT EXISTS creatives (
+      id          TEXT PRIMARY KEY,
+      name        TEXT NOT NULL,
+      template_id TEXT NOT NULL REFERENCES templates(id),
+      theme       TEXT NOT NULL DEFAULT '{}',
+      status      TEXT NOT NULL DEFAULT 'draft',
+      created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+      updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE TABLE IF NOT EXISTS creative_assets (
+      id          INTEGER PRIMARY KEY AUTOINCREMENT,
+      creative_id TEXT NOT NULL REFERENCES creatives(id) ON DELETE CASCADE,
+      file_key    TEXT NOT NULL,
+      file_name   TEXT NOT NULL,
+      file_path   TEXT NOT NULL,
+      file_size   INTEGER,
+      is_required INTEGER NOT NULL DEFAULT 1,
+      created_at  TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE TABLE IF NOT EXISTS builds (
+      id            TEXT PRIMARY KEY,
+      creative_id   TEXT NOT NULL REFERENCES creatives(id) ON DELETE CASCADE,
+      status        TEXT NOT NULL DEFAULT 'pending',
+      platforms     TEXT NOT NULL,
+      theme_snapshot TEXT NOT NULL,
+      results       TEXT,
+      error_log     TEXT,
+      started_at    TEXT,
+      finished_at   TEXT,
+      created_at    TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE INDEX IF NOT EXISTS idx_creatives_template ON creatives(template_id);
+    CREATE INDEX IF NOT EXISTS idx_builds_creative ON builds(creative_id);
+    CREATE INDEX IF NOT EXISTS idx_creative_assets_creative ON creative_assets(creative_id);
+  `);
+    console.log("[db] Database initialized at", dbPath);
+    return db;
+}
+//# sourceMappingURL=database.js.map

+ 1 - 0
platform/server/dist/db/database.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":";;;;;AAIA,oCAgEC;AApED,oEAAsC;AACtC,gDAAwB;AACxB,4CAAoB;AAEpB,SAAgB,YAAY,CAAC,UAAkB;IAC7C,kBAAkB;IAClB,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QAC/B,YAAE,CAAC,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,MAAM,GAAG,cAAI,CAAC,IAAI,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;IAChD,MAAM,EAAE,GAAG,IAAI,wBAAQ,CAAC,MAAM,CAAC,CAAC;IAEhC,eAAe;IACf,EAAE,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;IAChC,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;IAE/B,KAAK;IACL,EAAE,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CP,CAAC,CAAC;IAEH,OAAO,CAAC,GAAG,CAAC,8BAA8B,EAAE,MAAM,CAAC,CAAC;IACpD,OAAO,EAAE,CAAC;AACZ,CAAC"}

+ 3 - 0
platform/server/dist/db/seed.d.ts

@@ -0,0 +1,3 @@
+import Database from "better-sqlite3";
+export declare function seedTemplates(db: Database.Database): void;
+//# sourceMappingURL=seed.d.ts.map

+ 1 - 0
platform/server/dist/db/seed.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["../../src/db/seed.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAStC,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,GAAG,IAAI,CAwBzD"}

+ 29 - 0
platform/server/dist/db/seed.js

@@ -0,0 +1,29 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.seedTemplates = seedTemplates;
+const fs_1 = __importDefault(require("fs"));
+const path_1 = __importDefault(require("path"));
+const COLORING_MANIFEST_PATH = path_1.default.resolve(__dirname, "../../../../templates/coloring/manifest.json");
+function seedTemplates(db) {
+    // 检查 coloring 模板是否已注册
+    const existing = db
+        .prepare("SELECT id FROM templates WHERE id = ?")
+        .get("coloring");
+    if (existing) {
+        // 更新 manifest(开发阶段可能频繁变更)
+        const manifest = fs_1.default.readFileSync(COLORING_MANIFEST_PATH, "utf-8");
+        const manifestJson = JSON.parse(manifest);
+        db.prepare("UPDATE templates SET manifest = ?, name = ?, updated_at = datetime('now') WHERE id = ?").run(manifest, manifestJson.name, "coloring");
+        console.log("[db] Template 'coloring' updated");
+        return;
+    }
+    // 首次注册
+    const manifest = fs_1.default.readFileSync(COLORING_MANIFEST_PATH, "utf-8");
+    const manifestJson = JSON.parse(manifest);
+    db.prepare("INSERT INTO templates (id, name, manifest) VALUES (?, ?, ?)").run("coloring", manifestJson.name, manifest);
+    console.log("[db] Template 'coloring' seeded");
+}
+//# sourceMappingURL=seed.js.map

+ 1 - 0
platform/server/dist/db/seed.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"seed.js","sourceRoot":"","sources":["../../src/db/seed.ts"],"names":[],"mappings":";;;;;AASA,sCAwBC;AAhCD,4CAAoB;AACpB,gDAAwB;AAExB,MAAM,sBAAsB,GAAG,cAAI,CAAC,OAAO,CACzC,SAAS,EACT,8CAA8C,CAC/C,CAAC;AAEF,SAAgB,aAAa,CAAC,EAAqB;IACjD,sBAAsB;IACtB,MAAM,QAAQ,GAAG,EAAE;SAChB,OAAO,CAAC,uCAAuC,CAAC;SAChD,GAAG,CAAC,UAAU,CAAC,CAAC;IAEnB,IAAI,QAAQ,EAAE,CAAC;QACb,0BAA0B;QAC1B,MAAM,QAAQ,GAAG,YAAE,CAAC,YAAY,CAAC,sBAAsB,EAAE,OAAO,CAAC,CAAC;QAClE,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAC1C,EAAE,CAAC,OAAO,CACR,wFAAwF,CACzF,CAAC,GAAG,CAAC,QAAQ,EAAE,YAAY,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QAC/C,OAAO,CAAC,GAAG,CAAC,kCAAkC,CAAC,CAAC;QAChD,OAAO;IACT,CAAC;IAED,OAAO;IACP,MAAM,QAAQ,GAAG,YAAE,CAAC,YAAY,CAAC,sBAAsB,EAAE,OAAO,CAAC,CAAC;IAClE,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC1C,EAAE,CAAC,OAAO,CACR,6DAA6D,CAC9D,CAAC,GAAG,CAAC,UAAU,EAAE,YAAY,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC/C,OAAO,CAAC,GAAG,CAAC,iCAAiC,CAAC,CAAC;AACjD,CAAC"}

+ 2 - 0
platform/server/dist/index.d.ts

@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=index.d.ts.map

+ 1 - 0
platform/server/dist/index.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}

+ 51 - 0
platform/server/dist/index.js

@@ -0,0 +1,51 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const express_1 = __importDefault(require("express"));
+const cors_1 = __importDefault(require("cors"));
+const path_1 = __importDefault(require("path"));
+const database_1 = require("./db/database");
+const seed_1 = require("./db/seed");
+const templates_1 = require("./routes/templates");
+const creatives_1 = require("./routes/creatives");
+const assets_1 = require("./routes/assets");
+const builds_1 = require("./routes/builds");
+const preview_1 = require("./routes/preview");
+const preview_2 = require("./routes/preview");
+const errorHandler_1 = require("./middleware/errorHandler");
+const PORT = process.env.PORT || 3001;
+const STORAGE_DIR = path_1.default.resolve(__dirname, "../../../storage");
+const CLIENT_DIST = path_1.default.resolve(__dirname, "../../client/dist");
+async function main() {
+    // 初始化数据库
+    const db = (0, database_1.initDatabase)(STORAGE_DIR);
+    (0, seed_1.seedTemplates)(db);
+    const app = (0, express_1.default)();
+    // 中间件
+    app.use((0, cors_1.default)());
+    app.use(express_1.default.json());
+    // API 路由
+    app.use("/api/v1/templates", (0, templates_1.templatesRouter)(db));
+    app.use("/api/v1/creatives", (0, creatives_1.creativesRouter)(db, STORAGE_DIR, preview_2.onThemeSaved));
+    app.use("/api/v1", (0, assets_1.assetsRouter)(db, STORAGE_DIR));
+    app.use("/api/v1", (0, builds_1.buildsRouter)(db, STORAGE_DIR, preview_2.onThemeSaved));
+    app.use("/api/v1", (0, preview_1.previewRouter)(db, STORAGE_DIR));
+    // 生产环境:serve React 静态文件
+    app.use(express_1.default.static(CLIENT_DIST));
+    app.get("*", (_req, res) => {
+        res.sendFile(path_1.default.join(CLIENT_DIST, "index.html"));
+    });
+    // 错误处理
+    app.use(errorHandler_1.errorHandler);
+    app.listen(PORT, () => {
+        console.log(`[platform] Server running at http://localhost:${PORT}`);
+        console.log(`[platform] Storage: ${STORAGE_DIR}`);
+    });
+}
+main().catch((err) => {
+    console.error("Failed to start server:", err);
+    process.exit(1);
+});
+//# sourceMappingURL=index.js.map

+ 1 - 0
platform/server/dist/index.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,sDAA8B;AAC9B,gDAAwB;AACxB,gDAAwB;AACxB,4CAA6C;AAC7C,oCAA0C;AAC1C,kDAAqD;AACrD,kDAAqD;AACrD,4CAA+C;AAC/C,4CAA+C;AAC/C,8CAAiD;AACjD,8CAAgD;AAChD,4DAAyD;AAEzD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AACtC,MAAM,WAAW,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,kBAAkB,CAAC,CAAC;AAChE,MAAM,WAAW,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,mBAAmB,CAAC,CAAC;AAEjE,KAAK,UAAU,IAAI;IACjB,SAAS;IACT,MAAM,EAAE,GAAG,IAAA,uBAAY,EAAC,WAAW,CAAC,CAAC;IACrC,IAAA,oBAAa,EAAC,EAAE,CAAC,CAAC;IAElB,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;IAEtB,MAAM;IACN,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,GAAE,CAAC,CAAC;IAChB,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IAExB,SAAS;IACT,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAA,2BAAe,EAAC,EAAE,CAAC,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAA,2BAAe,EAAC,EAAE,EAAE,WAAW,EAAE,sBAAY,CAAC,CAAC,CAAC;IAC7E,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,IAAA,qBAAY,EAAC,EAAE,EAAE,WAAW,CAAC,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,IAAA,qBAAY,EAAC,EAAE,EAAE,WAAW,EAAE,sBAAY,CAAC,CAAC,CAAC;IAChE,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,IAAA,uBAAa,EAAC,EAAE,EAAE,WAAW,CAAC,CAAC,CAAC;IAEnD,wBAAwB;IACxB,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC;IACrC,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QACzB,GAAG,CAAC,QAAQ,CAAC,cAAI,CAAC,IAAI,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC,CAAC;IACrD,CAAC,CAAC,CAAC;IAEH,OAAO;IACP,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;IAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;QACpB,OAAO,CAAC,GAAG,CAAC,iDAAiD,IAAI,EAAE,CAAC,CAAC;QACrE,OAAO,CAAC,GAAG,CAAC,uBAAuB,WAAW,EAAE,CAAC,CAAC;IACpD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,yBAAyB,EAAE,GAAG,CAAC,CAAC;IAC9C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}

+ 3 - 0
platform/server/dist/middleware/errorHandler.d.ts

@@ -0,0 +1,3 @@
+import { Request, Response, NextFunction } from "express";
+export declare function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void;
+//# sourceMappingURL=errorHandler.d.ts.map

+ 1 - 0
platform/server/dist/middleware/errorHandler.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"errorHandler.d.ts","sourceRoot":"","sources":["../../src/middleware/errorHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,wBAAgB,YAAY,CAC1B,GAAG,EAAE,KAAK,EACV,IAAI,EAAE,OAAO,EACb,GAAG,EAAE,QAAQ,EACb,KAAK,EAAE,YAAY,GAClB,IAAI,CAON"}

+ 12 - 0
platform/server/dist/middleware/errorHandler.js

@@ -0,0 +1,12 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.errorHandler = errorHandler;
+function errorHandler(err, _req, res, _next) {
+    console.error("[error]", err.message, err.stack);
+    res.status(500).json({
+        error: {
+            message: err.message || "Internal Server Error",
+        },
+    });
+}
+//# sourceMappingURL=errorHandler.js.map

+ 1 - 0
platform/server/dist/middleware/errorHandler.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"errorHandler.js","sourceRoot":"","sources":["../../src/middleware/errorHandler.ts"],"names":[],"mappings":";;AAEA,oCAYC;AAZD,SAAgB,YAAY,CAC1B,GAAU,EACV,IAAa,EACb,GAAa,EACb,KAAmB;IAEnB,OAAO,CAAC,KAAK,CAAC,SAAS,EAAE,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;IACjD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;QACnB,KAAK,EAAE;YACL,OAAO,EAAE,GAAG,CAAC,OAAO,IAAI,uBAAuB;SAChD;KACF,CAAC,CAAC;AACL,CAAC"}

+ 4 - 0
platform/server/dist/routes/assets.d.ts

@@ -0,0 +1,4 @@
+import { Router } from "express";
+import Database from "better-sqlite3";
+export declare function assetsRouter(db: Database.Database, storageDir: string): Router;
+//# sourceMappingURL=assets.d.ts.map

+ 1 - 0
platform/server/dist/routes/assets.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../src/routes/assets.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAqGtC,wBAAgB,YAAY,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA8F9E"}

+ 148 - 0
platform/server/dist/routes/assets.js

@@ -0,0 +1,148 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.assetsRouter = assetsRouter;
+const express_1 = require("express");
+const multer_1 = __importDefault(require("multer"));
+const adm_zip_1 = __importDefault(require("adm-zip"));
+const path_1 = __importDefault(require("path"));
+const fs_1 = __importDefault(require("fs"));
+const storageService_1 = require("../services/storageService");
+const upload = (0, multer_1.default)({
+    storage: multer_1.default.memoryStorage(),
+    limits: { fileSize: 50 * 1024 * 1024 },
+    fileFilter: (_req, file, cb) => {
+        if (file.mimetype === "application/zip" ||
+            file.mimetype === "application/x-zip-compressed" ||
+            file.originalname.endsWith(".zip")) {
+            cb(null, true);
+        }
+        else {
+            cb(new Error("Only .zip files are allowed"));
+        }
+    },
+});
+/**
+ * 从 zip buffer 中提取素材文件、校验、写入磁盘、更新 DB。
+ * 文件上传和 URL 导入共用此逻辑。
+ */
+function extractAndSave(zipBuffer, manifest, assetsDir, db, creativeId) {
+    const requiredAssets = manifest.assets?.required ?? [];
+    const optionalAssets = manifest.assets?.optional ?? [];
+    const allAssetDefs = [...requiredAssets, ...optionalAssets];
+    // 清理并重建素材目录
+    if (fs_1.default.existsSync(assetsDir)) {
+        fs_1.default.rmSync(assetsDir, { recursive: true, force: true });
+    }
+    fs_1.default.mkdirSync(assetsDir, { recursive: true });
+    const zip = new adm_zip_1.default(zipBuffer);
+    const zipEntries = zip.getEntries();
+    const extractedFiles = [];
+    const warnings = [];
+    // 清空旧素材记录
+    db.prepare("DELETE FROM creative_assets WHERE creative_id = ?").run(creativeId);
+    // 匹配并解压文件
+    for (const def of allAssetDefs) {
+        const entry = zipEntries.find((e) => {
+            const entryName = path_1.default.basename(e.entryName).toLowerCase();
+            const expectedName = def.file.toLowerCase();
+            return entryName === expectedName;
+        });
+        if (entry) {
+            const fileName = def.file;
+            const filePath = path_1.default.join(assetsDir, fileName);
+            fs_1.default.writeFileSync(filePath, entry.getData());
+            const stat = fs_1.default.statSync(filePath);
+            const isRequired = requiredAssets.some((r) => r.key === def.key);
+            db.prepare("INSERT INTO creative_assets (creative_id, file_key, file_name, file_path, file_size, is_required) VALUES (?, ?, ?, ?, ?, ?)").run(creativeId, def.key, fileName, filePath, stat.size, isRequired ? 1 : 0);
+            extractedFiles.push({ key: def.key, fileName, fileSize: stat.size, valid: true });
+        }
+        else if (requiredAssets.some((r) => r.key === def.key)) {
+            warnings.push(`Required file '${def.file}' is missing from uploaded zip`);
+            extractedFiles.push({ key: def.key, fileName: def.file, fileSize: 0, valid: false });
+        }
+    }
+    // 检查未知文件
+    for (const entry of zipEntries) {
+        if (entry.isDirectory)
+            continue;
+        const entryName = path_1.default.basename(entry.entryName).toLowerCase();
+        const known = allAssetDefs.some((d) => d.file.toLowerCase() === entryName);
+        if (!known) {
+            warnings.push(`Unknown file '${entry.entryName}' ignored`);
+        }
+    }
+    return { files: extractedFiles, warnings };
+}
+function assetsRouter(db, storageDir) {
+    const router = (0, express_1.Router)();
+    // POST /api/v1/creatives/:id/assets/upload
+    // 支持两种方式:
+    //   - multipart/form-data 上传 .zip 文件(file 字段)
+    //   - application/json 提供素材 URL({ url: "https://..." })
+    router.post("/creatives/:id/assets/upload", upload.single("file"), async (req, res) => {
+        try {
+            const creativeId = req.params.id;
+            // 校验创意存在
+            const creative = db
+                .prepare("SELECT c.*, t.manifest FROM creatives c JOIN templates t ON c.template_id = t.id WHERE c.id = ?")
+                .get(creativeId);
+            if (!creative) {
+                res.status(404).json({ error: { message: "Creative not found" } });
+                return;
+            }
+            const manifest = JSON.parse(creative.manifest);
+            const assetsDir = path_1.default.join(storageDir, "creatives", creativeId, "assets");
+            let zipBuffer = null;
+            // 方式 1:文件上传
+            if (req.file) {
+                zipBuffer = req.file.buffer;
+            }
+            // 方式 2:URL 导入
+            else if (req.body?.url) {
+                const parsed = (0, storageService_1.parseDetailUrl)(req.body.url);
+                if (!parsed) {
+                    res.status(400).json({
+                        error: { message: "无法解析素材 URL,请确认格式正确" },
+                    });
+                    return;
+                }
+                console.log(`[assets] Downloading encrypted zip: ${parsed.zipUrl}`);
+                const encrypted = await (0, storageService_1.downloadFile)(parsed.zipUrl);
+                console.log(`[assets] Decrypting with key: ${parsed.id}`);
+                zipBuffer = (0, storageService_1.xorDecryptBuffer)(encrypted, parsed.id);
+            }
+            else {
+                res.status(400).json({
+                    error: { message: "请上传素材 zip 文件或提供素材 URL" },
+                });
+                return;
+            }
+            // 共用解压 & 校验逻辑
+            const { files, warnings } = extractAndSave(zipBuffer, manifest, assetsDir, db, creativeId);
+            // 更新创意状态
+            const missingRequired = files.some((f) => !f.valid);
+            db.prepare("UPDATE creatives SET status = ?, updated_at = datetime('now') WHERE id = ?").run(missingRequired ? "draft" : "assets_ready", creativeId);
+            res.json({ data: { files, warnings } });
+        }
+        catch (err) {
+            console.error("[assets] Upload error:", err.message);
+            res.status(500).json({ error: { message: err.message } });
+        }
+    });
+    // DELETE /api/v1/creatives/:id/assets — 清除素材
+    router.delete("/creatives/:id/assets", (req, res) => {
+        const creativeId = req.params.id;
+        const assetsDir = path_1.default.join(storageDir, "creatives", creativeId, "assets");
+        if (fs_1.default.existsSync(assetsDir)) {
+            fs_1.default.rmSync(assetsDir, { recursive: true, force: true });
+        }
+        db.prepare("DELETE FROM creative_assets WHERE creative_id = ?").run(creativeId);
+        db.prepare("UPDATE creatives SET status = 'draft', updated_at = datetime('now') WHERE id = ?").run(creativeId);
+        res.json({ data: { id: creativeId, cleared: true } });
+    });
+    return router;
+}
+//# sourceMappingURL=assets.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/server/dist/routes/assets.js.map


+ 6 - 0
platform/server/dist/routes/builds.d.ts

@@ -0,0 +1,6 @@
+import { Router } from "express";
+import Database from "better-sqlite3";
+type ThemeSavedCallback = (creativeId: string, theme: Record<string, string>, storageDir: string) => void;
+export declare function buildsRouter(db: Database.Database, storageDir: string, onThemeSaved?: ThemeSavedCallback): Router;
+export {};
+//# sourceMappingURL=builds.d.ts.map

+ 1 - 0
platform/server/dist/routes/builds.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"builds.d.ts","sourceRoot":"","sources":["../../src/routes/builds.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAMtC,KAAK,kBAAkB,GAAG,CACxB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC7B,UAAU,EAAE,MAAM,KACf,IAAI,CAAC;AAEV,wBAAgB,YAAY,CAC1B,EAAE,EAAE,QAAQ,CAAC,QAAQ,EACrB,UAAU,EAAE,MAAM,EAClB,YAAY,CAAC,EAAE,kBAAkB,GAChC,MAAM,CAiLR"}

+ 140 - 0
platform/server/dist/routes/builds.js

@@ -0,0 +1,140 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.buildsRouter = buildsRouter;
+const express_1 = require("express");
+const uuid_1 = require("uuid");
+const path_1 = __importDefault(require("path"));
+const fs_1 = __importDefault(require("fs"));
+const buildService_1 = require("../services/buildService");
+function buildsRouter(db, storageDir, onThemeSaved) {
+    const router = (0, express_1.Router)();
+    const buildService = new buildService_1.BuildService(db, storageDir);
+    // POST /api/v1/creatives/:id/builds — 触发构建
+    router.post("/creatives/:id/builds", async (req, res) => {
+        try {
+            const { id } = req.params;
+            const { platforms, theme } = req.body;
+            if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
+                res.status(400).json({
+                    error: { message: "platforms must be a non-empty array" },
+                });
+                return;
+            }
+            const creative = db
+                .prepare("SELECT * FROM creatives WHERE id = ?")
+                .get(id);
+            if (!creative) {
+                res.status(404).json({ error: { message: "Creative not found" } });
+                return;
+            }
+            if (creative.status === "draft") {
+                res.status(400).json({
+                    error: { message: "Please upload assets before building" },
+                });
+                return;
+            }
+            // 保存 theme
+            if (theme) {
+                db.prepare("UPDATE creatives SET theme = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(theme), id);
+                onThemeSaved?.(id, theme, storageDir);
+            }
+            const buildId = (0, uuid_1.v4)();
+            const themeSnapshot = theme || JSON.parse(creative.theme || "{}");
+            db.prepare(`INSERT INTO builds (id, creative_id, platforms, theme_snapshot, status)
+         VALUES (?, ?, ?, ?, 'pending')`).run(buildId, id, JSON.stringify(platforms), JSON.stringify(themeSnapshot));
+            // 更新创意状态
+            db.prepare("UPDATE creatives SET status = 'building', updated_at = datetime('now') WHERE id = ?").run(id);
+            // 异步构建(不阻塞响应)
+            buildService.enqueue(buildId, id, platforms, themeSnapshot);
+            res.status(201).json({
+                data: { id: buildId, status: "pending", platforms },
+            });
+        }
+        catch (err) {
+            res.status(500).json({ error: { message: err.message } });
+        }
+    });
+    // GET /api/v1/creatives/:id/builds — 构建历史
+    router.get("/creatives/:id/builds", (req, res) => {
+        const builds = db
+            .prepare("SELECT * FROM builds WHERE creative_id = ? ORDER BY created_at DESC LIMIT 20")
+            .all(req.params.id);
+        res.json({
+            data: builds.map((b) => ({
+                id: b.id,
+                creativeId: b.creative_id,
+                status: b.status,
+                platforms: JSON.parse(b.platforms),
+                results: b.results ? JSON.parse(b.results) : null,
+                errorLog: b.error_log,
+                startedAt: b.started_at,
+                finishedAt: b.finished_at,
+                createdAt: b.created_at,
+            })),
+        });
+    });
+    // GET /api/v1/builds/:id/status — 构建状态轮询
+    router.get("/builds/:id/status", (req, res) => {
+        const build = db
+            .prepare("SELECT * FROM builds WHERE id = ?")
+            .get(req.params.id);
+        if (!build) {
+            res.status(404).json({ error: { message: "Build not found" } });
+            return;
+        }
+        res.json({
+            data: {
+                id: build.id,
+                status: build.status,
+                results: build.results ? JSON.parse(build.results) : null,
+                errorLog: build.error_log,
+                startedAt: build.started_at,
+                finishedAt: build.finished_at,
+                createdAt: build.created_at,
+            },
+        });
+    });
+    // GET /api/v1/builds/:id/download/all — 下载全部产物 ZIP(必须在 :platform 之前注册)
+    router.get("/builds/:id/download/all", (req, res) => {
+        const build = db
+            .prepare("SELECT * FROM builds WHERE id = ?")
+            .get(req.params.id);
+        if (!build || build.status !== "completed") {
+            res.status(404).json({ error: { message: "Build not found or not completed" } });
+            return;
+        }
+        const zipPath = path_1.default.join(storageDir, "creatives", build.creative_id, "builds", build.id, "all.zip");
+        if (!fs_1.default.existsSync(zipPath)) {
+            res.status(404).json({ error: { message: "ZIP file not found" } });
+            return;
+        }
+        res.download(zipPath, `playable-ad-${build.id.slice(0, 8)}.zip`);
+    });
+    // GET /api/v1/builds/:id/download/:platform — 下载单个平台产物
+    router.get("/builds/:id/download/:platform", (req, res) => {
+        const build = db
+            .prepare("SELECT * FROM builds WHERE id = ?")
+            .get(req.params.id);
+        if (!build || build.status !== "completed") {
+            res.status(404).json({ error: { message: "Build not found or not completed" } });
+            return;
+        }
+        const results = JSON.parse(build.results || "[]");
+        const result = results.find((r) => r.platform === req.params.platform);
+        if (!result) {
+            res.status(404).json({ error: { message: "Platform not found in build results" } });
+            return;
+        }
+        const filePath = path_1.default.join(storageDir, "creatives", build.creative_id, "builds", build.id, req.params.platform, "index.html");
+        if (!fs_1.default.existsSync(filePath)) {
+            res.status(404).json({ error: { message: "File not found on disk" } });
+            return;
+        }
+        res.download(filePath, "index.html");
+    });
+    return router;
+}
+//# sourceMappingURL=builds.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/server/dist/routes/builds.js.map


+ 6 - 0
platform/server/dist/routes/creatives.d.ts

@@ -0,0 +1,6 @@
+import { Router } from "express";
+import Database from "better-sqlite3";
+type ThemeSavedCallback = (creativeId: string, theme: Record<string, string>, storageDir: string) => void;
+export declare function creativesRouter(db: Database.Database, storageDir: string, onThemeSaved?: ThemeSavedCallback): Router;
+export {};
+//# sourceMappingURL=creatives.d.ts.map

+ 1 - 0
platform/server/dist/routes/creatives.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"creatives.d.ts","sourceRoot":"","sources":["../../src/routes/creatives.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAKtC,KAAK,kBAAkB,GAAG,CACxB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC7B,UAAU,EAAE,MAAM,KACf,IAAI,CAAC;AAEV,wBAAgB,eAAe,CAC7B,EAAE,EAAE,QAAQ,CAAC,QAAQ,EACrB,UAAU,EAAE,MAAM,EAClB,YAAY,CAAC,EAAE,kBAAkB,GAChC,MAAM,CA+MR"}

+ 182 - 0
platform/server/dist/routes/creatives.js

@@ -0,0 +1,182 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.creativesRouter = creativesRouter;
+const express_1 = require("express");
+const uuid_1 = require("uuid");
+const fs_1 = __importDefault(require("fs"));
+const path_1 = __importDefault(require("path"));
+function creativesRouter(db, storageDir, onThemeSaved) {
+    const router = (0, express_1.Router)();
+    // GET /api/v1/creatives — 创意列表
+    router.get("/", (req, res) => {
+        const { status, page = "1", limit = "20" } = req.query;
+        const offset = (Number(page) - 1) * Number(limit);
+        let sql = `
+      SELECT c.*, t.name as template_name
+      FROM creatives c
+      JOIN templates t ON c.template_id = t.id
+    `;
+        const params = [];
+        if (status) {
+            sql += " WHERE c.status = ?";
+            params.push(status);
+        }
+        sql += " ORDER BY c.updated_at DESC LIMIT ? OFFSET ?";
+        params.push(Number(limit), offset);
+        const rows = db.prepare(sql).all(...params);
+        const data = rows.map((row) => ({
+            id: row.id,
+            name: row.name,
+            templateId: row.template_id,
+            templateName: row.template_name,
+            status: row.status,
+            createdAt: row.created_at,
+            updatedAt: row.updated_at,
+        }));
+        res.json({ data });
+    });
+    // POST /api/v1/creatives — 新建创意
+    router.post("/", (req, res) => {
+        const { name, templateId } = req.body;
+        if (!name || !templateId) {
+            res.status(400).json({
+                error: { message: "name and templateId are required" },
+            });
+            return;
+        }
+        // 校验模板存在
+        const template = db
+            .prepare("SELECT id FROM templates WHERE id = ?")
+            .get(templateId);
+        if (!template) {
+            res.status(400).json({
+                error: { message: `Template '${templateId}' not found` },
+            });
+            return;
+        }
+        const id = (0, uuid_1.v4)();
+        db.prepare("INSERT INTO creatives (id, name, template_id) VALUES (?, ?, ?)").run(id, name, templateId);
+        const creative = db.prepare("SELECT * FROM creatives WHERE id = ?").get(id);
+        res.status(201).json({
+            data: {
+                id: creative.id,
+                name: creative.name,
+                templateId: creative.template_id,
+                status: creative.status,
+                theme: JSON.parse(creative.theme),
+                createdAt: creative.created_at,
+                updatedAt: creative.updated_at,
+            },
+        });
+    });
+    // GET /api/v1/creatives/:id — 创意详情
+    router.get("/:id", (req, res) => {
+        const creative = db
+            .prepare(`SELECT c.*, t.name as template_name
+         FROM creatives c
+         JOIN templates t ON c.template_id = t.id
+         WHERE c.id = ?`)
+            .get(req.params.id);
+        if (!creative) {
+            res.status(404).json({ error: { message: "Creative not found" } });
+            return;
+        }
+        // 获取素材列表
+        const assets = db
+            .prepare("SELECT * FROM creative_assets WHERE creative_id = ?")
+            .all(creative.id);
+        // 获取最近构建
+        const recentBuilds = db
+            .prepare("SELECT id, status, platforms, results, error_log, started_at, finished_at, created_at FROM builds WHERE creative_id = ? ORDER BY created_at DESC LIMIT 5")
+            .all(creative.id);
+        res.json({
+            data: {
+                id: creative.id,
+                name: creative.name,
+                templateId: creative.template_id,
+                template: {
+                    id: creative.template_id,
+                    name: creative.template_name,
+                },
+                status: creative.status,
+                theme: JSON.parse(creative.theme),
+                assets: assets.map((a) => ({
+                    key: a.file_key,
+                    fileName: a.file_name,
+                    fileSize: a.file_size,
+                    isRequired: !!a.is_required,
+                })),
+                recentBuilds: recentBuilds.map((b) => ({
+                    id: b.id,
+                    status: b.status,
+                    platforms: JSON.parse(b.platforms),
+                    results: b.results ? JSON.parse(b.results) : null,
+                    errorLog: b.error_log,
+                    startedAt: b.started_at,
+                    finishedAt: b.finished_at,
+                    createdAt: b.created_at,
+                })),
+                createdAt: creative.created_at,
+                updatedAt: creative.updated_at,
+            },
+        });
+    });
+    // PATCH /api/v1/creatives/:id — 更新创意
+    router.patch("/:id", (req, res) => {
+        const creative = db
+            .prepare("SELECT * FROM creatives WHERE id = ?")
+            .get(req.params.id);
+        if (!creative) {
+            res.status(404).json({ error: { message: "Creative not found" } });
+            return;
+        }
+        const { name, theme } = req.body;
+        if (name !== undefined) {
+            db.prepare("UPDATE creatives SET name = ?, updated_at = datetime('now') WHERE id = ?")
+                .run(name, req.params.id);
+        }
+        if (theme !== undefined) {
+            db.prepare("UPDATE creatives SET theme = ?, updated_at = datetime('now') WHERE id = ?")
+                .run(JSON.stringify(theme), req.params.id);
+            // 通知预览服务更新配置
+            onThemeSaved?.(req.params.id, theme, storageDir);
+        }
+        // 返回更新后的数据
+        const updated = db
+            .prepare("SELECT * FROM creatives WHERE id = ?")
+            .get(req.params.id);
+        res.json({
+            data: {
+                id: updated.id,
+                name: updated.name,
+                templateId: updated.template_id,
+                status: updated.status,
+                theme: JSON.parse(updated.theme),
+                updatedAt: updated.updated_at,
+            },
+        });
+    });
+    // DELETE /api/v1/creatives/:id — 删除创意
+    router.delete("/:id", (req, res) => {
+        const creative = db
+            .prepare("SELECT * FROM creatives WHERE id = ?")
+            .get(req.params.id);
+        if (!creative) {
+            res.status(404).json({ error: { message: "Creative not found" } });
+            return;
+        }
+        // 删除磁盘文件
+        const creativeDir = path_1.default.join(storageDir, "creatives", creative.id);
+        if (fs_1.default.existsSync(creativeDir)) {
+            fs_1.default.rmSync(creativeDir, { recursive: true, force: true });
+        }
+        // 级联删除数据库记录(foreign key ON DELETE CASCADE)
+        db.prepare("DELETE FROM creatives WHERE id = ?").run(req.params.id);
+        res.json({ data: { id: req.params.id, deleted: true } });
+    });
+    return router;
+}
+//# sourceMappingURL=creatives.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/server/dist/routes/creatives.js.map


+ 8 - 0
platform/server/dist/routes/preview.d.ts

@@ -0,0 +1,8 @@
+import { Router } from "express";
+import Database from "better-sqlite3";
+export declare function previewRouter(db: Database.Database, storageDir: string): Router;
+/**
+ * 当 Theme 保存时,若预览正在运行则更新配置
+ */
+export declare function onThemeSaved(creativeId: string, theme: Record<string, string>, storageDir: string): void;
+//# sourceMappingURL=preview.d.ts.map

+ 1 - 0
platform/server/dist/routes/preview.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../src/routes/preview.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAGtC,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAuC/E;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC7B,UAAU,EAAE,MAAM,GACjB,IAAI,CAEN"}

+ 47 - 0
platform/server/dist/routes/preview.js

@@ -0,0 +1,47 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.previewRouter = previewRouter;
+exports.onThemeSaved = onThemeSaved;
+const express_1 = require("express");
+const previewService_1 = require("../services/previewService");
+function previewRouter(db, storageDir) {
+    const router = (0, express_1.Router)();
+    // POST /api/v1/creatives/:id/preview/start
+    router.post("/creatives/:id/preview/start", async (req, res) => {
+        try {
+            const creativeId = req.params.id;
+            const creative = db
+                .prepare("SELECT * FROM creatives WHERE id = ?")
+                .get(creativeId);
+            if (!creative) {
+                res.status(404).json({ error: { message: "Creative not found" } });
+                return;
+            }
+            if (creative.status === "draft") {
+                res.status(400).json({
+                    error: { message: "请先上传素材后再预览" },
+                });
+                return;
+            }
+            const theme = req.body?.theme || JSON.parse(creative.theme || "{}");
+            const result = await (0, previewService_1.startPreview)(creativeId, theme, storageDir);
+            res.json({ data: result });
+        }
+        catch (err) {
+            res.status(500).json({ error: { message: err.message } });
+        }
+    });
+    // POST /api/v1/creatives/:id/preview/stop
+    router.post("/creatives/:id/preview/stop", (_req, res) => {
+        (0, previewService_1.stopPreview)();
+        res.json({ data: { stopped: true } });
+    });
+    return router;
+}
+/**
+ * 当 Theme 保存时,若预览正在运行则更新配置
+ */
+function onThemeSaved(creativeId, theme, storageDir) {
+    (0, previewService_1.updatePreviewConfig)(creativeId, theme, storageDir);
+}
+//# sourceMappingURL=preview.js.map

+ 1 - 0
platform/server/dist/routes/preview.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"preview.js","sourceRoot":"","sources":["../../src/routes/preview.ts"],"names":[],"mappings":";;AAIA,sCAuCC;AAKD,oCAMC;AAtDD,qCAAiC;AAEjC,+DAA4F;AAE5F,SAAgB,aAAa,CAAC,EAAqB,EAAE,UAAkB;IACrE,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;IAExB,2CAA2C;IAC3C,MAAM,CAAC,IAAI,CAAC,8BAA8B,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;QAC7D,IAAI,CAAC;YACH,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,CAAC,EAAY,CAAC;YAE3C,MAAM,QAAQ,GAAG,EAAE;iBAChB,OAAO,CAAC,sCAAsC,CAAC;iBAC/C,GAAG,CAAC,UAAU,CAAQ,CAAC;YAC1B,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,OAAO,EAAE,oBAAoB,EAAE,EAAE,CAAC,CAAC;gBACnE,OAAO;YACT,CAAC;YAED,IAAI,QAAQ,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;gBAChC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;oBACnB,KAAK,EAAE,EAAE,OAAO,EAAE,YAAY,EAAE;iBACjC,CAAC,CAAC;gBACH,OAAO;YACT,CAAC;YAED,MAAM,KAAK,GAAG,GAAG,CAAC,IAAI,EAAE,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;YACpE,MAAM,MAAM,GAAG,MAAM,IAAA,6BAAY,EAAC,UAAU,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC;YAEjE,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;QAC7B,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QAC5D,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,0CAA0C;IAC1C,MAAM,CAAC,IAAI,CAAC,6BAA6B,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QACvD,IAAA,4BAAW,GAAE,CAAC;QACd,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;IACxC,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;GAEG;AACH,SAAgB,YAAY,CAC1B,UAAkB,EAClB,KAA6B,EAC7B,UAAkB;IAElB,IAAA,oCAAmB,EAAC,UAAU,EAAE,KAAK,EAAE,UAAU,CAAC,CAAC;AACrD,CAAC"}

+ 4 - 0
platform/server/dist/routes/templates.d.ts

@@ -0,0 +1,4 @@
+import { Router } from "express";
+import Database from "better-sqlite3";
+export declare function templatesRouter(db: Database.Database): Router;
+//# sourceMappingURL=templates.d.ts.map

+ 1 - 0
platform/server/dist/routes/templates.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../src/routes/templates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAEtC,wBAAgB,eAAe,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,GAAG,MAAM,CAiD7D"}

+ 48 - 0
platform/server/dist/routes/templates.js

@@ -0,0 +1,48 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.templatesRouter = templatesRouter;
+const express_1 = require("express");
+function templatesRouter(db) {
+    const router = (0, express_1.Router)();
+    // GET /api/v1/templates — 模板列表
+    router.get("/", (_req, res) => {
+        const rows = db
+            .prepare("SELECT id, name, manifest FROM templates ORDER BY created_at DESC")
+            .all();
+        const data = rows.map((row) => {
+            const manifest = JSON.parse(row.manifest);
+            return {
+                id: row.id,
+                name: row.name,
+                description: manifest.description,
+                version: manifest.version,
+                platforms: manifest.platforms?.available ?? [],
+                assetCount: {
+                    required: manifest.assets?.required?.length ?? 0,
+                    optional: manifest.assets?.optional?.length ?? 0,
+                },
+            };
+        });
+        res.json({ data });
+    });
+    // GET /api/v1/templates/:id — 单个模板详情
+    router.get("/:id", (req, res) => {
+        const row = db
+            .prepare("SELECT id, name, manifest FROM templates WHERE id = ?")
+            .get(req.params.id);
+        if (!row) {
+            res.status(404).json({ error: { message: "Template not found" } });
+            return;
+        }
+        const manifest = JSON.parse(row.manifest);
+        res.json({
+            data: {
+                id: row.id,
+                name: row.name,
+                ...manifest,
+            },
+        });
+    });
+    return router;
+}
+//# sourceMappingURL=templates.js.map

+ 1 - 0
platform/server/dist/routes/templates.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"templates.js","sourceRoot":"","sources":["../../src/routes/templates.ts"],"names":[],"mappings":";;AAGA,0CAiDC;AApDD,qCAAiC;AAGjC,SAAgB,eAAe,CAAC,EAAqB;IACnD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;IAExB,+BAA+B;IAC/B,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;QAC5B,MAAM,IAAI,GAAG,EAAE;aACZ,OAAO,CAAC,mEAAmE,CAAC;aAC5E,GAAG,EAA2D,CAAC;QAElE,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE;YAC5B,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YAC1C,OAAO;gBACL,EAAE,EAAE,GAAG,CAAC,EAAE;gBACV,IAAI,EAAE,GAAG,CAAC,IAAI;gBACd,WAAW,EAAE,QAAQ,CAAC,WAAW;gBACjC,OAAO,EAAE,QAAQ,CAAC,OAAO;gBACzB,SAAS,EAAE,QAAQ,CAAC,SAAS,EAAE,SAAS,IAAI,EAAE;gBAC9C,UAAU,EAAE;oBACV,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,CAAC;oBAChD,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,CAAC;iBACjD;aACF,CAAC;QACJ,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;IACrB,CAAC,CAAC,CAAC;IAEH,qCAAqC;IACrC,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAC9B,MAAM,GAAG,GAAG,EAAE;aACX,OAAO,CAAC,uDAAuD,CAAC;aAChE,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAA+D,CAAC;QAEpF,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,OAAO,EAAE,oBAAoB,EAAE,EAAE,CAAC,CAAC;YACnE,OAAO;QACT,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC1C,GAAG,CAAC,IAAI,CAAC;YACP,IAAI,EAAE;gBACJ,EAAE,EAAE,GAAG,CAAC,EAAE;gBACV,IAAI,EAAE,GAAG,CAAC,IAAI;gBACd,GAAG,QAAQ;aACZ;SACF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC"}

+ 15 - 0
platform/server/dist/services/buildService.d.ts

@@ -0,0 +1,15 @@
+import Database from "better-sqlite3";
+export declare class BuildService {
+    private db;
+    private storageDir;
+    private queue;
+    private running;
+    constructor(db: Database.Database, storageDir: string);
+    enqueue(buildId: string, creativeId: string, platforms: string[], theme: Record<string, string>): void;
+    private processQueue;
+    private build;
+    private runViteBuild;
+    private collectOutput;
+    private createZip;
+}
+//# sourceMappingURL=buildService.d.ts.map

+ 1 - 0
platform/server/dist/services/buildService.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"buildService.d.ts","sourceRoot":"","sources":["../../src/services/buildService.ts"],"names":[],"mappings":"AAIA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAOtC,qBAAa,YAAY;IAIX,OAAO,CAAC,EAAE;IAAqB,OAAO,CAAC,UAAU;IAH7D,OAAO,CAAC,KAAK,CAAkC;IAC/C,OAAO,CAAC,OAAO,CAAS;gBAEJ,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAU,UAAU,EAAE,MAAM;IAErE,OAAO,CACL,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EAAE,EACnB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC5B,IAAI;YAOO,YAAY;YAaZ,KAAK;IAiFnB,OAAO,CAAC,YAAY;YAkBN,aAAa;IAmB3B,OAAO,CAAC,SAAS;CAwBlB"}

+ 147 - 0
platform/server/dist/services/buildService.js

@@ -0,0 +1,147 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.BuildService = void 0;
+const child_process_1 = require("child_process");
+const path_1 = __importDefault(require("path"));
+const fs_1 = __importDefault(require("fs"));
+const archiver_1 = __importDefault(require("archiver"));
+const configGenerator_1 = require("./configGenerator");
+const storageService_1 = require("./storageService");
+const TEMPLATE_DIR = path_1.default.resolve(__dirname, "../../../../templates/coloring");
+const BUILD_TIMEOUT_MS = 120_000; // 单次构建超时 120s
+class BuildService {
+    db;
+    storageDir;
+    queue = [];
+    running = false;
+    constructor(db, storageDir) {
+        this.db = db;
+        this.storageDir = storageDir;
+    }
+    enqueue(buildId, creativeId, platforms, theme) {
+        this.queue.push(() => this.build(buildId, creativeId, platforms, theme));
+        if (!this.running) {
+            this.processQueue();
+        }
+    }
+    async processQueue() {
+        this.running = true;
+        while (this.queue.length > 0) {
+            const task = this.queue.shift();
+            try {
+                await task();
+            }
+            catch (err) {
+                console.error("[build] Queue task failed:", err);
+            }
+        }
+        this.running = false;
+    }
+    async build(buildId, creativeId, platforms, theme) {
+        const startTime = new Date().toISOString();
+        try {
+            // 更新状态 → building
+            this.db
+                .prepare("UPDATE builds SET status = 'building', started_at = ? WHERE id = ?")
+                .run(startTime, buildId);
+            // 1. 创建 symlink
+            (0, configGenerator_1.createAssetsSymlink)(creativeId, this.storageDir);
+            // 2. 生成 _ad_config_.ts
+            const configContent = (0, configGenerator_1.generateAdConfig)({
+                creativeId,
+                theme,
+                storageDir: this.storageDir,
+            });
+            const configPath = path_1.default.join(TEMPLATE_DIR, "src", "filler", "_ad_config_.ts");
+            fs_1.default.writeFileSync(configPath, configContent, "utf-8");
+            console.log(`[build] Generated _ad_config_.ts for creative ${creativeId}`);
+            // 3. 构建输出目录
+            const buildOutputDir = path_1.default.join(this.storageDir, "creatives", creativeId, "builds", buildId);
+            (0, storageService_1.ensureDir)(buildOutputDir);
+            // 4. 逐平台构建
+            const results = [];
+            for (const platform of platforms) {
+                console.log(`[build] Building ${platform} for creative ${creativeId}...`);
+                await this.runViteBuild(platform);
+                await this.collectOutput(buildOutputDir, platform, results);
+            }
+            // 5. 打包 ZIP
+            await this.createZip(buildOutputDir, results);
+            // 6. 更新数据库
+            const finishedAt = new Date().toISOString();
+            this.db
+                .prepare(`UPDATE builds SET status = 'completed', results = ?, finished_at = ? WHERE id = ?`)
+                .run(JSON.stringify(results), finishedAt, buildId);
+            // 更新创意状态
+            this.db
+                .prepare("UPDATE creatives SET status = 'built', updated_at = datetime('now') WHERE id = ?")
+                .run(creativeId);
+            console.log(`[build] Build ${buildId} completed: ${results.map((r) => r.platform).join(", ")}`);
+        }
+        catch (err) {
+            console.error(`[build] Build ${buildId} failed:`, err.message);
+            this.db
+                .prepare("UPDATE builds SET status = 'failed', error_log = ? WHERE id = ?")
+                .run(err.message || "Unknown error", buildId);
+            this.db
+                .prepare("UPDATE creatives SET status = 'assets_ready', updated_at = datetime('now') WHERE id = ?")
+                .run(creativeId);
+        }
+        finally {
+            // 清理临时文件
+            (0, configGenerator_1.cleanupBuildArtifacts)();
+        }
+    }
+    runViteBuild(platform) {
+        return new Promise((resolve, reject) => {
+            const cmd = `cd ${TEMPLATE_DIR} && AD_CONFIG_PATH=src/filler/_ad_config_.ts npx vite build --mode ${platform}`;
+            console.log(`[build] Executing: ${cmd}`);
+            (0, child_process_1.exec)(cmd, { timeout: BUILD_TIMEOUT_MS }, (error, stdout, stderr) => {
+                if (stdout)
+                    console.log(`[vite:${platform}]`, stdout.slice(-500));
+                if (stderr && !stderr.includes("vite"))
+                    console.error(`[vite:${platform}]`, stderr.slice(-500));
+                if (error) {
+                    reject(new Error(`Vite build failed for ${platform}: ${error.message}`));
+                }
+                else {
+                    resolve();
+                }
+            });
+        });
+    }
+    async collectOutput(buildOutputDir, platform, results) {
+        const distPath = path_1.default.join(TEMPLATE_DIR, "dist", platform, "index.html");
+        const destDir = path_1.default.join(buildOutputDir, platform);
+        (0, storageService_1.ensureDir)(destDir);
+        const destPath = path_1.default.join(destDir, "index.html");
+        if (!fs_1.default.existsSync(distPath)) {
+            throw new Error(`Build output not found for platform ${platform}: ${distPath}`);
+        }
+        fs_1.default.copyFileSync(distPath, destPath);
+        const stat = fs_1.default.statSync(destPath);
+        results.push({ platform, fileSize: stat.size });
+    }
+    createZip(buildOutputDir, results) {
+        return new Promise((resolve, reject) => {
+            const zipPath = path_1.default.join(buildOutputDir, "all.zip");
+            const output = fs_1.default.createWriteStream(zipPath);
+            const archive = (0, archiver_1.default)("zip", { zlib: { level: 9 } });
+            output.on("close", resolve);
+            archive.on("error", reject);
+            archive.pipe(output);
+            for (const r of results) {
+                const filePath = path_1.default.join(buildOutputDir, r.platform, "index.html");
+                if (fs_1.default.existsSync(filePath)) {
+                    archive.file(filePath, { name: `${r.platform}/index.html` });
+                }
+            }
+            archive.finalize();
+        });
+    }
+}
+exports.BuildService = BuildService;
+//# sourceMappingURL=buildService.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/server/dist/services/buildService.js.map


+ 24 - 0
platform/server/dist/services/configGenerator.d.ts

@@ -0,0 +1,24 @@
+interface GenerateInput {
+    creativeId: string;
+    theme: Record<string, string>;
+    storageDir: string;
+}
+/**
+ * 扫描用户素材目录,生成 _ad_config_.ts 内容。
+ *
+ * 关键逻辑:
+ * - 必填素材 (config/page/map) → 必定 import
+ * - optional 素材 (special) → 存在才 import,不存在则不生成对应字段
+ * - 模板自有素材 → import 指向 templates/coloring/assets/
+ */
+export declare function generateAdConfig(input: GenerateInput): string;
+/**
+ * 创建 symlink:templates/coloring/assets/user/ → storage/creatives/<id>/assets/
+ */
+export declare function createAssetsSymlink(creativeId: string, storageDir: string): void;
+/**
+ * 清理构建产生的临时文件
+ */
+export declare function cleanupBuildArtifacts(): void;
+export {};
+//# sourceMappingURL=configGenerator.d.ts.map

+ 1 - 0
platform/server/dist/services/configGenerator.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"configGenerator.d.ts","sourceRoot":"","sources":["../../src/services/configGenerator.ts"],"names":[],"mappings":"AAMA,UAAU,aAAa;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAuD7D;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAgBhF;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAO5C"}

+ 99 - 0
platform/server/dist/services/configGenerator.js

@@ -0,0 +1,99 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.generateAdConfig = generateAdConfig;
+exports.createAssetsSymlink = createAssetsSymlink;
+exports.cleanupBuildArtifacts = cleanupBuildArtifacts;
+const fs_1 = __importDefault(require("fs"));
+const path_1 = __importDefault(require("path"));
+const storageService_1 = require("./storageService");
+const TEMPLATE_DIR = path_1.default.resolve(__dirname, "../../../../templates/coloring");
+/**
+ * 扫描用户素材目录,生成 _ad_config_.ts 内容。
+ *
+ * 关键逻辑:
+ * - 必填素材 (config/page/map) → 必定 import
+ * - optional 素材 (special) → 存在才 import,不存在则不生成对应字段
+ * - 模板自有素材 → import 指向 templates/coloring/assets/
+ */
+function generateAdConfig(input) {
+    const assetsDir = path_1.default.join(input.storageDir, "creatives", input.creativeId, "assets");
+    const files = (0, storageService_1.scanAssetFiles)(assetsDir);
+    const lines = [];
+    // == 用户素材 ==
+    lines.push("// ==== 用户上传素材 ====");
+    lines.push(`import configRaw from "/assets/user/config.json?raw";`);
+    lines.push(`import pageUrl from "/assets/user/page.png?url";`);
+    lines.push(`import mapUrl from "/assets/user/map.png?url";`);
+    let hasSpecial = false;
+    if (files.special) {
+        hasSpecial = true;
+        lines.push(`import specialUrl from "/assets/user/${files.special}?url";`);
+    }
+    // == 模板自有素材 ==
+    lines.push("");
+    lines.push("// ==== 模板自有素材 ====");
+    lines.push(`import numberFontUrl from "/assets/fonts/numbers_roboto_500.png?url";`);
+    lines.push(`import fingerUrl from "/assets/img/finger.png?url";`);
+    lines.push(`import logoUrl from "/assets/img/logo.png?url";`);
+    lines.push(`import logoTxtUrl from "/assets/img/logo-txt.png?url";`);
+    lines.push(`import coloringPagesUrl from "/assets/img/coloring-pages.png?url";`);
+    lines.push(`import slogonUrl from "/assets/img/slogon.png?url";`);
+    // == adAssets 导出 ==
+    lines.push("");
+    lines.push("export const adAssets = {");
+    lines.push("  configRaw,");
+    lines.push("  pageUrl,");
+    lines.push("  mapUrl,");
+    if (hasSpecial) {
+        lines.push("  specialUrl,");
+    }
+    lines.push("  numberFontUrl,");
+    lines.push("  fingerUrl,");
+    lines.push("  logoUrl,");
+    lines.push("  logoTxtUrl,");
+    lines.push("  coloringPagesUrl,");
+    lines.push("  slogonUrl,");
+    lines.push("};");
+    // == adTheme 导出 ==
+    lines.push("");
+    lines.push("export const adTheme = {");
+    lines.push(`  bgGradient: ${JSON.stringify(input.theme.bgGradient || "linear-gradient(160deg, #fff9f2 0%, #ffeedd 100%)")},`);
+    lines.push(`  ctaGradient: ${JSON.stringify(input.theme.ctaGradient || "linear-gradient(135deg, #ff5f1f 0%, #ffb300 100%)")},`);
+    lines.push(`  ctaText: ${JSON.stringify(input.theme.ctaText || "PLAY NOW")},`);
+    lines.push(`  progressColor: ${JSON.stringify(input.theme.progressColor || "#07ce07")},`);
+    lines.push("};");
+    return lines.join("\n") + "\n";
+}
+/**
+ * 创建 symlink:templates/coloring/assets/user/ → storage/creatives/<id>/assets/
+ */
+function createAssetsSymlink(creativeId, storageDir) {
+    const symlinkPath = path_1.default.join(TEMPLATE_DIR, "assets", "user");
+    const targetPath = path_1.default.join(storageDir, "creatives", creativeId, "assets");
+    // 清理旧的 symlink
+    if (fs_1.default.existsSync(symlinkPath)) {
+        const stat = fs_1.default.lstatSync(symlinkPath);
+        if (stat.isSymbolicLink()) {
+            fs_1.default.unlinkSync(symlinkPath);
+        }
+        else {
+            fs_1.default.rmSync(symlinkPath, { recursive: true, force: true });
+        }
+    }
+    fs_1.default.symlinkSync(targetPath, symlinkPath, "dir");
+    console.log(`[config] Symlink created: ${symlinkPath} → ${targetPath}`);
+}
+/**
+ * 清理构建产生的临时文件
+ */
+function cleanupBuildArtifacts() {
+    const adConfigPath = path_1.default.join(TEMPLATE_DIR, "src", "filler", "_ad_config_.ts");
+    if (fs_1.default.existsSync(adConfigPath)) {
+        fs_1.default.unlinkSync(adConfigPath);
+    }
+    // symlink 保留,下次构建复用
+}
+//# sourceMappingURL=configGenerator.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/server/dist/services/configGenerator.js.map


+ 20 - 0
platform/server/dist/services/previewService.d.ts

@@ -0,0 +1,20 @@
+/**
+ * 启动实时预览。等待 Vite dev server 就绪后才返回。
+ */
+export declare function startPreview(creativeId: string, theme: Record<string, string>, storageDir: string): Promise<{
+    url: string;
+}>;
+/**
+ * 更新预览配置(主题变更时调用)。Vite HMR 会自动检测并刷新页面。
+ */
+export declare function updatePreviewConfig(creativeId: string, theme: Record<string, string>, storageDir: string): void;
+/**
+ * 停止预览
+ */
+export declare function stopPreview(): void;
+export declare function getPreviewStatus(): {
+    active: boolean;
+    creativeId: string | null;
+    url: string | null;
+};
+//# sourceMappingURL=previewService.d.ts.map

+ 1 - 0
platform/server/dist/services/previewService.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"previewService.d.ts","sourceRoot":"","sources":["../../src/services/previewService.ts"],"names":[],"mappings":"AAoCA;;GAEG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC7B,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAwC1B;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC7B,UAAU,EAAE,MAAM,GACjB,IAAI,CAON;AAED;;GAEG;AACH,wBAAgB,WAAW,IAAI,IAAI,CAgBlC;AAED,wBAAgB,gBAAgB,IAAI;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAMrG"}

+ 120 - 0
platform/server/dist/services/previewService.js

@@ -0,0 +1,120 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.startPreview = startPreview;
+exports.updatePreviewConfig = updatePreviewConfig;
+exports.stopPreview = stopPreview;
+exports.getPreviewStatus = getPreviewStatus;
+const child_process_1 = require("child_process");
+const child_process_2 = require("child_process");
+const path_1 = __importDefault(require("path"));
+const fs_1 = __importDefault(require("fs"));
+const http_1 = __importDefault(require("http"));
+const configGenerator_1 = require("./configGenerator");
+const TEMPLATE_DIR = path_1.default.resolve(__dirname, "../../../../templates/coloring");
+const PREVIEW_PORT = 5199;
+let viteProcess = null;
+let currentCreativeId = null;
+/**
+ * 等待 HTTP 服务就绪
+ */
+function waitForReady(url, maxRetries = 15) {
+    return new Promise((resolve, reject) => {
+        let tries = 0;
+        function check() {
+            http_1.default.get(url, (res) => {
+                if (res.statusCode === 200)
+                    resolve();
+                else
+                    retry();
+            }).on("error", retry);
+        }
+        function retry() {
+            if (++tries >= maxRetries) {
+                reject(new Error(`Preview server did not start within ${maxRetries}s`));
+                return;
+            }
+            setTimeout(check, 1000);
+        }
+        check();
+    });
+}
+/**
+ * 启动实时预览。等待 Vite dev server 就绪后才返回。
+ */
+async function startPreview(creativeId, theme, storageDir) {
+    // 1. 停止旧的预览(如果有)
+    stopPreview();
+    // 2. 创建 symlink
+    (0, configGenerator_1.createAssetsSymlink)(creativeId, storageDir);
+    // 3. 生成配置
+    const configContent = (0, configGenerator_1.generateAdConfig)({ creativeId, theme, storageDir });
+    const configPath = path_1.default.join(TEMPLATE_DIR, "src", "filler", "_ad_config_.ts");
+    fs_1.default.writeFileSync(configPath, configContent, "utf-8");
+    // 4. 启动 Vite dev server
+    console.log(`[preview] Starting Vite dev server on port ${PREVIEW_PORT}...`);
+    viteProcess = (0, child_process_1.spawn)("npx", ["vite", "--port", String(PREVIEW_PORT), "--strictPort"], {
+        cwd: TEMPLATE_DIR,
+        env: { ...process.env, AD_CONFIG_PATH: "src/filler/_ad_config_.ts" },
+        stdio: ["ignore", "pipe", "pipe"],
+    });
+    viteProcess.stdout?.on("data", (data) => {
+        console.log(`[preview:vite] ${data.toString().trim()}`);
+    });
+    viteProcess.stderr?.on("data", (data) => {
+        console.log(`[preview:vite] ${data.toString().trim()}`);
+    });
+    viteProcess.on("exit", (code) => {
+        console.log(`[preview] Vite dev server exited (code ${code})`);
+        viteProcess = null;
+        currentCreativeId = null;
+    });
+    currentCreativeId = creativeId;
+    // 5. 等待 Vite 就绪
+    console.log("[preview] Waiting for Vite to be ready...");
+    await waitForReady(`http://localhost:${PREVIEW_PORT}`);
+    console.log("[preview] Vite is ready.");
+    return { url: `http://localhost:${PREVIEW_PORT}` };
+}
+/**
+ * 更新预览配置(主题变更时调用)。Vite HMR 会自动检测并刷新页面。
+ */
+function updatePreviewConfig(creativeId, theme, storageDir) {
+    if (currentCreativeId !== creativeId)
+        return;
+    const configContent = (0, configGenerator_1.generateAdConfig)({ creativeId, theme, storageDir });
+    const configPath = path_1.default.join(TEMPLATE_DIR, "src", "filler", "_ad_config_.ts");
+    fs_1.default.writeFileSync(configPath, configContent, "utf-8");
+    console.log(`[preview] Config updated for creative ${creativeId}`);
+}
+/**
+ * 停止预览
+ */
+function stopPreview() {
+    if (viteProcess) {
+        console.log("[preview] Stopping Vite dev server...");
+        try {
+            viteProcess.kill("SIGTERM");
+        }
+        catch {
+            // ignore
+        }
+        viteProcess = null;
+    }
+    currentCreativeId = null;
+    // 确保端口释放
+    try {
+        (0, child_process_2.execSync)(`lsof -ti :${PREVIEW_PORT} | xargs kill -9 2>/dev/null`, { stdio: "ignore" });
+    }
+    catch { }
+}
+function getPreviewStatus() {
+    return {
+        active: viteProcess !== null && currentCreativeId !== null,
+        creativeId: currentCreativeId,
+        url: currentCreativeId ? `http://localhost:${PREVIEW_PORT}` : null,
+    };
+}
+//# sourceMappingURL=previewService.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/server/dist/services/previewService.js.map


+ 31 - 0
platform/server/dist/services/storageService.d.ts

@@ -0,0 +1,31 @@
+export declare function ensureDir(dir: string): void;
+/**
+ * 从填色详情页 URL 中提取素材 ID 并拼接 zip 下载地址。
+ *
+ * 输入:https://color2.jccytech.cn/app/zh/pages/detail/6a154397957ac783bac98e10
+ * 输出:https://color2.jccytech.cn/zips/v2/number_mini/1501/6a154397957ac783bac98e10.zip
+ */
+export declare function parseDetailUrl(detailUrl: string): {
+    id: string;
+    zipUrl: string;
+} | null;
+/**
+ * 下载远程文件到 Buffer
+ */
+export declare function downloadFile(url: string): Promise<Buffer>;
+/**
+ * XOR 解密 zip 文件。
+ *
+ * 密钥 = 文件名(不含扩展名),即素材 ID。
+ * 对整个文件逐字节异或解密,密钥循环使用。
+ */
+export declare function xorDecryptBuffer(encrypted: Buffer, key: string): Buffer;
+export declare function getCreativeAssetsDir(storageDir: string, creativeId: string): string;
+export declare function getBuildOutputDir(storageDir: string, creativeId: string, buildId: string): string;
+export declare function scanAssetFiles(assetsDir: string): {
+    config: boolean;
+    page: boolean;
+    map: boolean;
+    special: string | null;
+};
+//# sourceMappingURL=storageService.d.ts.map

+ 1 - 0
platform/server/dist/services/storageService.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"storageService.d.ts","sourceRoot":"","sources":["../../src/services/storageService.ts"],"names":[],"mappings":"AAKA,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAI3C;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAavF;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAczD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAUvE;AAED,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAGnF;AAED,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GACd,MAAM,CAIR;AAED,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,GAChB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAgB1E"}

+ 103 - 0
platform/server/dist/services/storageService.js

@@ -0,0 +1,103 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ensureDir = ensureDir;
+exports.parseDetailUrl = parseDetailUrl;
+exports.downloadFile = downloadFile;
+exports.xorDecryptBuffer = xorDecryptBuffer;
+exports.getCreativeAssetsDir = getCreativeAssetsDir;
+exports.getBuildOutputDir = getBuildOutputDir;
+exports.scanAssetFiles = scanAssetFiles;
+const fs_1 = __importDefault(require("fs"));
+const path_1 = __importDefault(require("path"));
+const https_1 = __importDefault(require("https"));
+const http_1 = __importDefault(require("http"));
+function ensureDir(dir) {
+    if (!fs_1.default.existsSync(dir)) {
+        fs_1.default.mkdirSync(dir, { recursive: true });
+    }
+}
+/**
+ * 从填色详情页 URL 中提取素材 ID 并拼接 zip 下载地址。
+ *
+ * 输入:https://color2.jccytech.cn/app/zh/pages/detail/6a154397957ac783bac98e10
+ * 输出:https://color2.jccytech.cn/zips/v2/number_mini/1501/6a154397957ac783bac98e10.zip
+ */
+function parseDetailUrl(detailUrl) {
+    try {
+        const url = new URL(detailUrl);
+        // 取路径最后一段作为 ID
+        const segments = url.pathname.split("/").filter(Boolean);
+        const id = segments[segments.length - 1];
+        if (!id || id.length < 20)
+            return null;
+        const zipUrl = `https://color2.jccytech.cn/zips/v2/number_mini/1501/${id}.zip`;
+        return { id, zipUrl };
+    }
+    catch {
+        return null;
+    }
+}
+/**
+ * 下载远程文件到 Buffer
+ */
+function downloadFile(url) {
+    return new Promise((resolve, reject) => {
+        const client = url.startsWith("https") ? https_1.default : http_1.default;
+        client.get(url, (res) => {
+            if (res.statusCode !== 200) {
+                reject(new Error(`Download failed: HTTP ${res.statusCode}`));
+                return;
+            }
+            const chunks = [];
+            res.on("data", (chunk) => chunks.push(chunk));
+            res.on("end", () => resolve(Buffer.concat(chunks)));
+            res.on("error", reject);
+        }).on("error", reject);
+    });
+}
+/**
+ * XOR 解密 zip 文件。
+ *
+ * 密钥 = 文件名(不含扩展名),即素材 ID。
+ * 对整个文件逐字节异或解密,密钥循环使用。
+ */
+function xorDecryptBuffer(encrypted, key) {
+    const keyBuf = Buffer.from(key);
+    const keyLen = keyBuf.length;
+    const decrypted = Buffer.alloc(encrypted.length);
+    for (let i = 0; i < encrypted.length; i++) {
+        decrypted[i] = encrypted[i] ^ keyBuf[i % keyLen];
+    }
+    return decrypted;
+}
+function getCreativeAssetsDir(storageDir, creativeId) {
+    const dir = path_1.default.join(storageDir, "creatives", creativeId, "assets");
+    return dir;
+}
+function getBuildOutputDir(storageDir, creativeId, buildId) {
+    const dir = path_1.default.join(storageDir, "creatives", creativeId, "builds", buildId);
+    ensureDir(dir);
+    return dir;
+}
+function scanAssetFiles(assetsDir) {
+    const result = { config: false, page: false, map: false, special: null };
+    if (!fs_1.default.existsSync(assetsDir))
+        return result;
+    const files = fs_1.default.readdirSync(assetsDir);
+    for (const file of files) {
+        const lower = file.toLowerCase();
+        if (lower === "config.json")
+            result.config = true;
+        else if (lower === "page.png" || lower === "page.jpg" || lower === "page.jpeg")
+            result.page = true;
+        else if (lower === "map.png")
+            result.map = true;
+        else if (lower.startsWith("special."))
+            result.special = file;
+    }
+    return result;
+}
+//# sourceMappingURL=storageService.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
platform/server/dist/services/storageService.js.map


+ 3088 - 0
platform/server/package-lock.json

@@ -0,0 +1,3088 @@
+{
+  "name": "playableads-platform-server",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "playableads-platform-server",
+      "version": "1.0.0",
+      "dependencies": {
+        "adm-zip": "^0.5.16",
+        "archiver": "^7.0.1",
+        "better-sqlite3": "^11.7.0",
+        "cors": "^2.8.5",
+        "express": "^4.21.2",
+        "multer": "^1.4.5-lts.2",
+        "uuid": "^11.1.0"
+      },
+      "devDependencies": {
+        "@types/adm-zip": "^0.5.7",
+        "@types/archiver": "^6.0.3",
+        "@types/better-sqlite3": "^7.6.12",
+        "@types/cors": "^2.8.17",
+        "@types/express": "^5.0.1",
+        "@types/multer": "^1.4.12",
+        "@types/uuid": "^10.0.0",
+        "tsx": "^4.19.3",
+        "typescript": "^5.8.3"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+      "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+      "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+      "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+      "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+      "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+      "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+      "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+      "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+      "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+      "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+      "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+      "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+      "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+      "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+      "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+      "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+      "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+      "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+      "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+      "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+      "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+      "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+      "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@isaacs/cliui": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+      "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^5.1.2",
+        "string-width-cjs": "npm:string-width@^4.2.0",
+        "strip-ansi": "^7.0.1",
+        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+        "wrap-ansi": "^8.1.0",
+        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@pkgjs/parseargs": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@types/adm-zip": {
+      "version": "0.5.8",
+      "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
+      "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/archiver": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
+      "integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/readdir-glob": "*"
+      }
+    },
+    "node_modules/@types/better-sqlite3": {
+      "version": "7.6.13",
+      "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+      "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/cors": {
+      "version": "2.8.19",
+      "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+      "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+      "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "^2"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+      "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/multer": {
+      "version": "1.4.13",
+      "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
+      "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/express": "*"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "25.9.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
+      "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": ">=7.24.0 <7.24.7"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/readdir-glob": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
+      "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/uuid": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+      "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/adm-zip": {
+      "version": "0.5.17",
+      "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
+      "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+      "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "6.2.3",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+      "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/append-field": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+      "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+      "license": "MIT"
+    },
+    "node_modules/archiver": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
+      "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
+      "license": "MIT",
+      "dependencies": {
+        "archiver-utils": "^5.0.2",
+        "async": "^3.2.4",
+        "buffer-crc32": "^1.0.0",
+        "readable-stream": "^4.0.0",
+        "readdir-glob": "^1.1.2",
+        "tar-stream": "^3.0.0",
+        "zip-stream": "^6.0.1"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/archiver-utils": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
+      "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
+      "license": "MIT",
+      "dependencies": {
+        "glob": "^10.0.0",
+        "graceful-fs": "^4.2.0",
+        "is-stream": "^2.0.1",
+        "lazystream": "^1.0.0",
+        "lodash": "^4.17.15",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/async": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+      "license": "MIT"
+    },
+    "node_modules/b4a": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
+      "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
+      "license": "Apache-2.0",
+      "peerDependencies": {
+        "react-native-b4a": "*"
+      },
+      "peerDependenciesMeta": {
+        "react-native-b4a": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "license": "MIT"
+    },
+    "node_modules/bare-events": {
+      "version": "2.9.1",
+      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz",
+      "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
+      "license": "Apache-2.0",
+      "peerDependencies": {
+        "bare-abort-controller": "*"
+      },
+      "peerDependenciesMeta": {
+        "bare-abort-controller": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/bare-fs": {
+      "version": "4.7.2",
+      "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz",
+      "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "bare-events": "^2.5.4",
+        "bare-path": "^3.0.0",
+        "bare-stream": "^2.6.4",
+        "bare-url": "^2.2.2",
+        "fast-fifo": "^1.3.2"
+      },
+      "engines": {
+        "bare": ">=1.16.0"
+      },
+      "peerDependencies": {
+        "bare-buffer": "*"
+      },
+      "peerDependenciesMeta": {
+        "bare-buffer": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/bare-os": {
+      "version": "3.9.1",
+      "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz",
+      "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "bare": ">=1.14.0"
+      }
+    },
+    "node_modules/bare-path": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz",
+      "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "bare-os": "^3.0.1"
+      }
+    },
+    "node_modules/bare-stream": {
+      "version": "2.13.1",
+      "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz",
+      "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "streamx": "^2.25.0",
+        "teex": "^1.0.1"
+      },
+      "peerDependencies": {
+        "bare-abort-controller": "*",
+        "bare-buffer": "*",
+        "bare-events": "*"
+      },
+      "peerDependenciesMeta": {
+        "bare-abort-controller": {
+          "optional": true
+        },
+        "bare-buffer": {
+          "optional": true
+        },
+        "bare-events": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/bare-url": {
+      "version": "2.4.3",
+      "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz",
+      "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "bare-path": "^3.0.0"
+      }
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/better-sqlite3": {
+      "version": "11.10.0",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+      "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.1"
+      }
+    },
+    "node_modules/bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/bl/node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/bl/node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
+      "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+      "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.2.1"
+      }
+    },
+    "node_modules/buffer-crc32": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+      "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/busboy": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+      "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+      "dependencies": {
+        "streamsearch": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=10.16.0"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "license": "ISC"
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "license": "MIT"
+    },
+    "node_modules/compress-commons": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
+      "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
+      "license": "MIT",
+      "dependencies": {
+        "crc-32": "^1.2.0",
+        "crc32-stream": "^6.0.0",
+        "is-stream": "^2.0.1",
+        "normalize-path": "^3.0.0",
+        "readable-stream": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/concat-stream": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+      "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+      "engines": [
+        "node >= 0.8"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/concat-stream/node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/concat-stream/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/concat-stream/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "license": "Apache-2.0",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/crc32-stream": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
+      "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
+      "license": "MIT",
+      "dependencies": {
+        "crc-32": "^1.2.0",
+        "readable-stream": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/eastasianwidth": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+      "license": "MIT"
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/emoji-regex": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "license": "MIT",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+      "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.0",
+        "@esbuild/android-arm": "0.28.0",
+        "@esbuild/android-arm64": "0.28.0",
+        "@esbuild/android-x64": "0.28.0",
+        "@esbuild/darwin-arm64": "0.28.0",
+        "@esbuild/darwin-x64": "0.28.0",
+        "@esbuild/freebsd-arm64": "0.28.0",
+        "@esbuild/freebsd-x64": "0.28.0",
+        "@esbuild/linux-arm": "0.28.0",
+        "@esbuild/linux-arm64": "0.28.0",
+        "@esbuild/linux-ia32": "0.28.0",
+        "@esbuild/linux-loong64": "0.28.0",
+        "@esbuild/linux-mips64el": "0.28.0",
+        "@esbuild/linux-ppc64": "0.28.0",
+        "@esbuild/linux-riscv64": "0.28.0",
+        "@esbuild/linux-s390x": "0.28.0",
+        "@esbuild/linux-x64": "0.28.0",
+        "@esbuild/netbsd-arm64": "0.28.0",
+        "@esbuild/netbsd-x64": "0.28.0",
+        "@esbuild/openbsd-arm64": "0.28.0",
+        "@esbuild/openbsd-x64": "0.28.0",
+        "@esbuild/openharmony-arm64": "0.28.0",
+        "@esbuild/sunos-x64": "0.28.0",
+        "@esbuild/win32-arm64": "0.28.0",
+        "@esbuild/win32-ia32": "0.28.0",
+        "@esbuild/win32-x64": "0.28.0"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/events": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.x"
+      }
+    },
+    "node_modules/events-universal": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
+      "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "bare-events": "^2.7.0"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "license": "(MIT OR WTFPL)",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/fast-fifo": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+      "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+      "license": "MIT"
+    },
+    "node_modules/file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+      "license": "MIT"
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/foreground-child": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+      "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+      "license": "ISC",
+      "dependencies": {
+        "cross-spawn": "^7.0.6",
+        "signal-exit": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "license": "MIT"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+      "license": "MIT"
+    },
+    "node_modules/glob": {
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+      "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+      "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+      "license": "ISC",
+      "dependencies": {
+        "foreground-child": "^3.1.0",
+        "jackspeak": "^3.1.2",
+        "minimatch": "^9.0.4",
+        "minipass": "^7.1.2",
+        "package-json-from-dist": "^1.0.0",
+        "path-scurry": "^1.11.1"
+      },
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "license": "ISC"
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jackspeak": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+      "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/cliui": "^8.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      },
+      "optionalDependencies": {
+        "@pkgjs/parseargs": "^0.11.0"
+      }
+    },
+    "node_modules/lazystream": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
+      "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
+      "license": "MIT",
+      "dependencies": {
+        "readable-stream": "^2.0.5"
+      },
+      "engines": {
+        "node": ">= 0.6.3"
+      }
+    },
+    "node_modules/lazystream/node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/lazystream/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/lazystream/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "license": "ISC"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.9",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+      "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/minipass": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+      "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "0.5.6",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+      "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+      "license": "MIT",
+      "dependencies": {
+        "minimist": "^1.2.6"
+      },
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/multer": {
+      "version": "1.4.5-lts.2",
+      "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
+      "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
+      "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
+      "license": "MIT",
+      "dependencies": {
+        "append-field": "^1.0.0",
+        "busboy": "^1.0.0",
+        "concat-stream": "^1.5.2",
+        "mkdirp": "^0.5.4",
+        "object-assign": "^4.1.1",
+        "type-is": "^1.6.4",
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/napi-build-utils": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-abi": {
+      "version": "3.92.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
+      "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/package-json-from-dist": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+      "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+      "license": "BlueOak-1.0.0"
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-scurry": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+      "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "lru-cache": "^10.2.0",
+        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+      "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^2.0.0",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/process": {
+      "version": "0.11.10",
+      "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+      "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/pump": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+      "license": "MIT",
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+      "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+      "license": "MIT",
+      "dependencies": {
+        "abort-controller": "^3.0.0",
+        "buffer": "^6.0.3",
+        "events": "^3.3.0",
+        "process": "^0.11.10",
+        "string_decoder": "^1.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/readdir-glob": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
+      "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "minimatch": "^5.1.0"
+      }
+    },
+    "node_modules/readdir-glob/node_modules/minimatch": {
+      "version": "5.1.9",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+      "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+      "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/streamsearch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+      "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/streamx": {
+      "version": "2.26.0",
+      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.26.0.tgz",
+      "integrity": "sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==",
+      "license": "MIT",
+      "dependencies": {
+        "events-universal": "^1.0.0",
+        "fast-fifo": "^1.3.2",
+        "text-decoder": "^1.1.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+      "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+      "license": "MIT",
+      "dependencies": {
+        "eastasianwidth": "^0.2.0",
+        "emoji-regex": "^9.2.2",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/string-width-cjs": {
+      "name": "string-width",
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "license": "MIT"
+    },
+    "node_modules/string-width-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+      "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^6.2.2"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/strip-ansi-cjs": {
+      "name": "strip-ansi",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-fs/node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/tar-fs/node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
+      "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",
+      "license": "MIT",
+      "dependencies": {
+        "b4a": "^1.6.4",
+        "bare-fs": "^4.5.5",
+        "fast-fifo": "^1.2.0",
+        "streamx": "^2.15.0"
+      }
+    },
+    "node_modules/teex": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
+      "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
+      "license": "MIT",
+      "dependencies": {
+        "streamx": "^2.12.5"
+      }
+    },
+    "node_modules/text-decoder": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
+      "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "b4a": "^1.6.4"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+      "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.28.0"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.24.6",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+      "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/uuid": {
+      "version": "11.1.1",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
+      "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/esm/bin/uuid"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wrap-ansi": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+      "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^6.1.0",
+        "string-width": "^5.0.1",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs": {
+      "name": "wrap-ansi",
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "license": "MIT"
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
+    "node_modules/zip-stream": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
+      "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
+      "license": "MIT",
+      "dependencies": {
+        "archiver-utils": "^5.0.0",
+        "compress-commons": "^6.0.2",
+        "readable-stream": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    }
+  }
+}

+ 31 - 0
platform/server/package.json

@@ -0,0 +1,31 @@
+{
+  "name": "playableads-platform-server",
+  "version": "1.0.0",
+  "description": "Playable Ads Platform - Server",
+  "private": true,
+  "scripts": {
+    "dev": "npx tsx watch src/index.ts",
+    "build": "npx tsc",
+    "start": "node dist/index.js"
+  },
+  "dependencies": {
+    "adm-zip": "^0.5.16",
+    "archiver": "^7.0.1",
+    "better-sqlite3": "^11.7.0",
+    "cors": "^2.8.5",
+    "express": "^4.21.2",
+    "multer": "^1.4.5-lts.2",
+    "uuid": "^11.1.0"
+  },
+  "devDependencies": {
+    "@types/adm-zip": "^0.5.7",
+    "@types/archiver": "^6.0.3",
+    "@types/better-sqlite3": "^7.6.12",
+    "@types/cors": "^2.8.17",
+    "@types/express": "^5.0.1",
+    "@types/multer": "^1.4.12",
+    "@types/uuid": "^10.0.0",
+    "tsx": "^4.19.3",
+    "typescript": "^5.8.3"
+  }
+}

+ 69 - 0
platform/server/src/db/database.ts

@@ -0,0 +1,69 @@
+import Database from "better-sqlite3";
+import path from "path";
+import fs from "fs";
+
+export function initDatabase(storageDir: string): Database.Database {
+  // 确保 storage 目录存在
+  if (!fs.existsSync(storageDir)) {
+    fs.mkdirSync(storageDir, { recursive: true });
+  }
+
+  const dbPath = path.join(storageDir, "data.db");
+  const db = new Database(dbPath);
+
+  // WAL 模式提升并发读取
+  db.pragma("journal_mode = WAL");
+  db.pragma("foreign_keys = ON");
+
+  // 建表
+  db.exec(`
+    CREATE TABLE IF NOT EXISTS templates (
+      id          TEXT PRIMARY KEY,
+      name        TEXT NOT NULL,
+      manifest    TEXT NOT NULL,
+      created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+      updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE TABLE IF NOT EXISTS creatives (
+      id          TEXT PRIMARY KEY,
+      name        TEXT NOT NULL,
+      template_id TEXT NOT NULL REFERENCES templates(id),
+      theme       TEXT NOT NULL DEFAULT '{}',
+      status      TEXT NOT NULL DEFAULT 'draft',
+      created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+      updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE TABLE IF NOT EXISTS creative_assets (
+      id          INTEGER PRIMARY KEY AUTOINCREMENT,
+      creative_id TEXT NOT NULL REFERENCES creatives(id) ON DELETE CASCADE,
+      file_key    TEXT NOT NULL,
+      file_name   TEXT NOT NULL,
+      file_path   TEXT NOT NULL,
+      file_size   INTEGER,
+      is_required INTEGER NOT NULL DEFAULT 1,
+      created_at  TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE TABLE IF NOT EXISTS builds (
+      id            TEXT PRIMARY KEY,
+      creative_id   TEXT NOT NULL REFERENCES creatives(id) ON DELETE CASCADE,
+      status        TEXT NOT NULL DEFAULT 'pending',
+      platforms     TEXT NOT NULL,
+      theme_snapshot TEXT NOT NULL,
+      results       TEXT,
+      error_log     TEXT,
+      started_at    TEXT,
+      finished_at   TEXT,
+      created_at    TEXT NOT NULL DEFAULT (datetime('now'))
+    );
+
+    CREATE INDEX IF NOT EXISTS idx_creatives_template ON creatives(template_id);
+    CREATE INDEX IF NOT EXISTS idx_builds_creative ON builds(creative_id);
+    CREATE INDEX IF NOT EXISTS idx_creative_assets_creative ON creative_assets(creative_id);
+  `);
+
+  console.log("[db] Database initialized at", dbPath);
+  return db;
+}

Някои файлове не бяха показани, защото твърде много файлове са промени