Explorar el Código

Initial commit

bob hace 2 semanas
commit
4671e08feb

+ 6 - 0
.env.example

@@ -0,0 +1,6 @@
+PORT=3001
+HOST=0.0.0.0
+DATABASE_URL=postgresql://xinghen:xinghen@localhost:5432/xinghen
+UPLOAD_DIR=./uploads
+MAX_UPLOAD_MB=100
+NODE_ENV=development

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules
+dist
+.env
+uploads/*
+!uploads/.gitkeep
+*.log
+.DS_Store

+ 19 - 0
Dockerfile

@@ -0,0 +1,19 @@
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+FROM node:22-alpine
+WORKDIR /app
+ENV NODE_ENV=production
+COPY package*.json ./
+RUN npm install --omit=dev
+COPY --from=build /app/dist ./dist
+COPY --from=build /app/server ./server
+COPY --from=build /app/tsconfig*.json ./
+RUN npm install tsx
+RUN mkdir -p uploads
+EXPOSE 3001
+CMD ["npm", "start"]

+ 40 - 0
README.md

@@ -0,0 +1,40 @@
+# 星痕 · 智能课程工坊
+
+首版 MVP 包含课程创建、PPT 课件上传、素材确认和提交制作。课程提交后进入“等待制作”状态。
+
+## 技术组成
+
+- React + Vite + TypeScript
+- Fastify Server
+- PostgreSQL
+- PPT 文件保存在服务器本地持久目录,PostgreSQL 保存课程和文件元数据
+
+## 本地启动
+
+1. 复制环境配置:`cp .env.example .env`
+2. 启动 PostgreSQL:`docker compose up -d postgres`
+3. 安装依赖:`npm install`
+4. 启动前后端:`npm run dev`
+5. 打开 `http://localhost:5173`
+
+Server 启动时会自动创建所需的数据表。
+
+## 公网部署
+
+推荐在带持久磁盘的 Linux 云服务器运行:
+
+```bash
+docker compose up -d --build
+```
+
+应用默认监听 `3001` 端口。生产环境应使用 Caddy 或 Nginx 配置域名、HTTPS 和反向代理,并修改 `docker-compose.yml` 中的 PostgreSQL 密码。
+
+`postgres_data` 和 `uploads_data` 都是持久卷。部署时必须备份这两个卷;若使用无状态或 Serverless 平台,本地上传文件可能在重启时丢失,应先迁移至对象存储。
+
+## 常用命令
+
+- `npm run dev`:开发模式
+- `npm run build`:生产构建
+- `npm run check`:TypeScript 检查
+- `npm start`:启动生产 Server
+- `npm run db:migrate`:手动初始化数据库

+ 31 - 0
docker-compose.yml

@@ -0,0 +1,31 @@
+services:
+  postgres:
+    image: postgres:16-alpine
+    restart: unless-stopped
+    environment:
+      POSTGRES_USER: xinghen
+      POSTGRES_PASSWORD: xinghen
+      POSTGRES_DB: xinghen
+    ports:
+      - "5432:5432"
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+  app:
+    build: .
+    restart: unless-stopped
+    depends_on:
+      - postgres
+    environment:
+      PORT: 3001
+      HOST: 0.0.0.0
+      NODE_ENV: production
+      DATABASE_URL: postgresql://xinghen:xinghen@postgres:5432/xinghen
+      UPLOAD_DIR: /app/uploads
+      MAX_UPLOAD_MB: 100
+    ports:
+      - "3001:3001"
+    volumes:
+      - uploads_data:/app/uploads
+volumes:
+  postgres_data:
+  uploads_data:

+ 11 - 0
index.html

@@ -0,0 +1,11 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="theme-color" content="#f6f8fc" />
+    <meta name="description" content="星痕智能课程工坊,让课程素材准备更清晰、更简单。" />
+    <title>星痕 · 智能课程工坊</title>
+  </head>
+  <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
+</html>

+ 4007 - 0
package-lock.json

@@ -0,0 +1,4007 @@
+{
+  "name": "xinghen-course-workshop",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "xinghen-course-workshop",
+      "version": "0.1.0",
+      "dependencies": {
+        "@fastify/cors": "^10.0.2",
+        "@fastify/multipart": "^9.0.3",
+        "@fastify/static": "^8.1.1",
+        "fastify": "^5.2.1",
+        "lucide-react": "^0.468.0",
+        "pg": "^8.13.1",
+        "react": "^19.0.0",
+        "react-dom": "^19.0.0",
+        "react-router-dom": "^7.1.1",
+        "zod": "^3.24.1"
+      },
+      "devDependencies": {
+        "@types/node": "^22.10.2",
+        "@types/pg": "^8.11.10",
+        "@types/react": "^19.0.2",
+        "@types/react-dom": "^19.0.2",
+        "@vitejs/plugin-react": "^4.3.4",
+        "concurrently": "^9.1.2",
+        "tsx": "^4.19.2",
+        "typescript": "~5.7.2",
+        "vite": "^6.0.5"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.8",
+      "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz",
+      "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@babel/types": "^7.29.8",
+        "@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.8",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz",
+      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.8"
+      },
+      "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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.8",
+      "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz",
+      "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.7",
+        "@babel/generator": "^7.29.8",
+        "@babel/helper-globals": "^7.29.7",
+        "@babel/parser": "^7.29.8",
+        "@babel/template": "^7.29.7",
+        "@babel/types": "^7.29.8",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.8",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
+      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+      "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.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+      "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+      "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+      "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+      "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+      "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+      "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+      "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+      "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+      "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+      "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+      "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+      "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+      "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+      "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+      "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+      "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+      "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+      "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+      "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+      "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@fastify/accept-negotiator": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz",
+      "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/@fastify/ajv-compiler": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmmirror.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz",
+      "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.12.0",
+        "ajv-formats": "^3.0.1",
+        "fast-uri": "^4.0.0"
+      }
+    },
+    "node_modules/@fastify/busboy": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmmirror.com/@fastify/busboy/-/busboy-3.2.1.tgz",
+      "integrity": "sha512-tgK4O+57iz5ycYNGXE5ZWj1ES03lD2XnnBYWSbU/3wYZRMQzCUq7Ycds/RdyBZQeL5MU4fxBt6lzbIWf/Bickw==",
+      "license": "MIT"
+    },
+    "node_modules/@fastify/cors": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmmirror.com/@fastify/cors/-/cors-10.1.0.tgz",
+      "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "fastify-plugin": "^5.0.0",
+        "mnemonist": "0.40.0"
+      }
+    },
+    "node_modules/@fastify/deepmerge": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmmirror.com/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
+      "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/@fastify/error": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz",
+      "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/@fastify/fast-json-stringify-compiler": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz",
+      "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "fast-json-stringify": "^7.0.0"
+      }
+    },
+    "node_modules/@fastify/forwarded": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/@fastify/forwarded/-/forwarded-3.0.2.tgz",
+      "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/@fastify/merge-json-schemas": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmmirror.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
+      "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "dequal": "^2.0.3"
+      }
+    },
+    "node_modules/@fastify/multipart": {
+      "version": "9.4.0",
+      "resolved": "https://registry.npmmirror.com/@fastify/multipart/-/multipart-9.4.0.tgz",
+      "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@fastify/busboy": "^3.0.0",
+        "@fastify/deepmerge": "^3.0.0",
+        "@fastify/error": "^4.0.0",
+        "fastify-plugin": "^5.0.0",
+        "secure-json-parse": "^4.0.0"
+      }
+    },
+    "node_modules/@fastify/proxy-addr": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
+      "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@fastify/forwarded": "^3.0.0",
+        "ipaddr.js": "^2.1.0"
+      }
+    },
+    "node_modules/@fastify/send": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/@fastify/send/-/send-4.1.1.tgz",
+      "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@lukeed/ms": "^2.0.2",
+        "escape-html": "~1.0.3",
+        "fast-decode-uri-component": "^1.0.1",
+        "http-errors": "^2.0.0",
+        "mime": "^3"
+      }
+    },
+    "node_modules/@fastify/static": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmmirror.com/@fastify/static/-/static-8.3.0.tgz",
+      "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@fastify/accept-negotiator": "^2.0.0",
+        "@fastify/send": "^4.0.0",
+        "content-disposition": "^0.5.4",
+        "fastify-plugin": "^5.0.0",
+        "fastq": "^1.17.1",
+        "glob": "^11.0.0"
+      }
+    },
+    "node_modules/@isaacs/cliui": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-9.0.0.tgz",
+      "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/@lukeed/ms": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/@lukeed/ms/-/ms-2.0.2.tgz",
+      "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+      "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^22.20 || ^24.12 || >=25"
+      }
+    },
+    "node_modules/@pinojs/redact": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz",
+      "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+      "license": "MIT"
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmmirror.com/@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.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+      "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+      "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+      "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+      "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+      "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+      "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+      "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+      "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+      "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+      "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+      "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+      "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+      "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+      "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+      "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+      "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+      "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+      "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+      "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+      "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+      "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+      "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+      "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
+      "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
+      "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.20.1",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz",
+      "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/pg": {
+      "version": "8.23.1",
+      "resolved": "https://registry.npmmirror.com/@types/pg/-/pg-8.23.1.tgz",
+      "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "pg-protocol": "*",
+        "pg-types": "^2.2.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.18",
+      "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz",
+      "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.4",
+      "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.4.tgz",
+      "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmmirror.com/@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/abstract-logging": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz",
+      "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
+      "license": "MIT"
+    },
+    "node_modules/ajv": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz",
+      "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ajv/node_modules/fast-uri": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.5.tgz",
+      "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/atomic-sleep": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+      "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/avvio": {
+      "version": "9.3.0",
+      "resolved": "https://registry.npmmirror.com/avvio/-/avvio-9.3.0.tgz",
+      "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@fastify/error": "^4.0.0",
+        "fastq": "^1.17.1"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+      "license": "MIT",
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.11.15",
+      "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
+      "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "5.0.9",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.9.tgz",
+      "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.8",
+      "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.8.tgz",
+      "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
+      "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.11.12",
+        "caniuse-lite": "^1.0.30001809",
+        "electron-to-chromium": "^1.5.402",
+        "node-releases": "^2.0.53",
+        "update-browserslist-db": "^1.3.0"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001809",
+      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
+      "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
+      "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/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chalk/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
+      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.1",
+        "wrap-ansi": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/concurrently": {
+      "version": "9.2.4",
+      "resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-9.2.4.tgz",
+      "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "chalk": "4.1.2",
+        "rxjs": "7.8.2",
+        "shell-quote": "1.9.0",
+        "supports-color": "8.1.1",
+        "tree-kill": "1.2.2",
+        "yargs": "17.7.2"
+      },
+      "bin": {
+        "conc": "dist/bin/concurrently.js",
+        "concurrently": "dist/bin/concurrently.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmmirror.com/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/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmmirror.com/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/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.411",
+      "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz",
+      "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
+      "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.2",
+        "@esbuild/android-arm": "0.28.2",
+        "@esbuild/android-arm64": "0.28.2",
+        "@esbuild/android-x64": "0.28.2",
+        "@esbuild/darwin-arm64": "0.28.2",
+        "@esbuild/darwin-x64": "0.28.2",
+        "@esbuild/freebsd-arm64": "0.28.2",
+        "@esbuild/freebsd-x64": "0.28.2",
+        "@esbuild/linux-arm": "0.28.2",
+        "@esbuild/linux-arm64": "0.28.2",
+        "@esbuild/linux-ia32": "0.28.2",
+        "@esbuild/linux-loong64": "0.28.2",
+        "@esbuild/linux-mips64el": "0.28.2",
+        "@esbuild/linux-ppc64": "0.28.2",
+        "@esbuild/linux-riscv64": "0.28.2",
+        "@esbuild/linux-s390x": "0.28.2",
+        "@esbuild/linux-x64": "0.28.2",
+        "@esbuild/netbsd-arm64": "0.28.2",
+        "@esbuild/netbsd-x64": "0.28.2",
+        "@esbuild/openbsd-arm64": "0.28.2",
+        "@esbuild/openbsd-x64": "0.28.2",
+        "@esbuild/openharmony-arm64": "0.28.2",
+        "@esbuild/sunos-x64": "0.28.2",
+        "@esbuild/win32-arm64": "0.28.2",
+        "@esbuild/win32-ia32": "0.28.2",
+        "@esbuild/win32-x64": "0.28.2"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/fast-decode-uri-component": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
+      "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
+      "license": "MIT"
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
+    "node_modules/fast-json-stringify": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz",
+      "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@fastify/merge-json-schemas": "^0.2.0",
+        "ajv": "^8.12.0",
+        "ajv-formats": "^3.0.1",
+        "fast-uri": "^4.0.0",
+        "json-schema-ref-resolver": "^3.0.0",
+        "rfdc": "^1.2.0"
+      }
+    },
+    "node_modules/fast-querystring": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/fast-querystring/-/fast-querystring-1.1.2.tgz",
+      "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-decode-uri-component": "^1.0.1"
+      }
+    },
+    "node_modules/fast-uri": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-4.1.2.tgz",
+      "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/fastify": {
+      "version": "5.12.1",
+      "resolved": "https://registry.npmmirror.com/fastify/-/fastify-5.12.1.tgz",
+      "integrity": "sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@fastify/ajv-compiler": "^4.0.5",
+        "@fastify/error": "^4.0.0",
+        "@fastify/fast-json-stringify-compiler": "^5.0.0",
+        "@fastify/proxy-addr": "^5.0.0",
+        "abstract-logging": "^2.0.1",
+        "avvio": "^9.0.0",
+        "fast-json-stringify": "^7.0.0",
+        "find-my-way": "^9.6.0",
+        "light-my-request": "^6.0.0",
+        "pino": "^9.14.0 || ^10.1.0",
+        "process-warning": "^5.1.0",
+        "rfdc": "^1.3.1",
+        "secure-json-parse": "^4.0.0",
+        "semver": "^7.6.0",
+        "toad-cache": "^3.7.0"
+      }
+    },
+    "node_modules/fastify-plugin": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
+      "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/fastify/node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/fastq": {
+      "version": "1.20.1",
+      "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
+      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+      "license": "ISC",
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/find-my-way": {
+      "version": "9.8.0",
+      "resolved": "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.8.0.tgz",
+      "integrity": "sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-querystring": "^1.0.0",
+        "safe-regex2": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/foreground-child": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/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/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/glob": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmmirror.com/glob/-/glob-11.1.0.tgz",
+      "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "foreground-child": "^3.3.1",
+        "jackspeak": "^4.1.1",
+        "minimatch": "^10.1.1",
+        "minipass": "^7.1.2",
+        "package-json-from-dist": "^1.0.0",
+        "path-scurry": "^2.0.0"
+      },
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/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/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
+      "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/jackspeak": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-4.2.3.tgz",
+      "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/cliui": "^9.0.0"
+      },
+      "engines": {
+        "node": "20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json-schema-ref-resolver": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
+      "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "dequal": "^2.0.3"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmmirror.com/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/light-my-request": {
+      "version": "6.6.0",
+      "resolved": "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz",
+      "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "cookie": "^1.0.1",
+        "process-warning": "^4.0.0",
+        "set-cookie-parser": "^2.6.0"
+      }
+    },
+    "node_modules/light-my-request/node_modules/process-warning": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz",
+      "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/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/lucide-react": {
+      "version": "0.468.0",
+      "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.468.0.tgz",
+      "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+      }
+    },
+    "node_modules/mime": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz",
+      "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "10.2.6",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz",
+      "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "brace-expansion": "^5.0.8"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/minipass": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
+      "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/mnemonist": {
+      "version": "0.40.0",
+      "resolved": "https://registry.npmmirror.com/mnemonist/-/mnemonist-0.40.0.tgz",
+      "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==",
+      "license": "MIT",
+      "dependencies": {
+        "obliterator": "^2.0.4"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.18",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
+      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+      "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.53",
+      "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.53.tgz",
+      "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/obliterator": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/obliterator/-/obliterator-2.0.5.tgz",
+      "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
+      "license": "MIT"
+    },
+    "node_modules/on-exit-leak-free": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+      "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/package-json-from-dist": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/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/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-scurry": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz",
+      "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "lru-cache": "^11.0.0",
+        "minipass": "^7.1.2"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/path-scurry/node_modules/lru-cache": {
+      "version": "11.5.2",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz",
+      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.23.0",
+      "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz",
+      "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.16.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.16.0.tgz",
+      "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pino": {
+      "version": "10.3.1",
+      "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz",
+      "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
+      "license": "MIT",
+      "dependencies": {
+        "@pinojs/redact": "^0.4.0",
+        "atomic-sleep": "^1.0.0",
+        "on-exit-leak-free": "^2.1.0",
+        "pino-abstract-transport": "^3.0.0",
+        "pino-std-serializers": "^7.0.0",
+        "process-warning": "^5.0.0",
+        "quick-format-unescaped": "^4.0.3",
+        "real-require": "^0.2.0",
+        "safe-stable-stringify": "^2.3.1",
+        "sonic-boom": "^4.0.1",
+        "thread-stream": "^4.0.0"
+      },
+      "bin": {
+        "pino": "bin.js"
+      }
+    },
+    "node_modules/pino-abstract-transport": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+      "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.0.0"
+      }
+    },
+    "node_modules/pino-std-serializers": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+      "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+      "license": "MIT"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.26",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
+      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+      "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.17",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/process-warning": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.0.tgz",
+      "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/quick-format-unescaped": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+      "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+      "license": "MIT"
+    },
+    "node_modules/react": {
+      "version": "19.2.8",
+      "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
+      "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.8",
+      "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
+      "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.8"
+      }
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmmirror.com/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": "7.18.2",
+      "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.18.2.tgz",
+      "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "7.18.2",
+      "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.18.2.tgz",
+      "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
+      "license": "MIT",
+      "dependencies": {
+        "react-router": "7.18.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      }
+    },
+    "node_modules/real-require": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz",
+      "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12.13.0"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ret": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz",
+      "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "license": "MIT",
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rfdc": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+      "license": "MIT"
+    },
+    "node_modules/rollup": {
+      "version": "4.62.4",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.4.tgz",
+      "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
+      "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": {
+        "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+        "@rollup/rollup-android-arm-eabi": "4.62.4",
+        "@rollup/rollup-android-arm64": "4.62.4",
+        "@rollup/rollup-darwin-arm64": "4.62.4",
+        "@rollup/rollup-darwin-x64": "4.62.4",
+        "@rollup/rollup-freebsd-arm64": "4.62.4",
+        "@rollup/rollup-freebsd-x64": "4.62.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+        "@rollup/rollup-linux-arm64-musl": "4.62.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+        "@rollup/rollup-linux-loong64-musl": "4.62.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+        "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+        "@rollup/rollup-linux-x64-gnu": "4.62.4",
+        "@rollup/rollup-linux-x64-musl": "4.62.4",
+        "@rollup/rollup-openbsd-x64": "4.62.4",
+        "@rollup/rollup-openharmony-arm64": "4.62.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+        "@rollup/rollup-win32-x64-gnu": "4.62.4",
+        "@rollup/rollup-win32-x64-msvc": "4.62.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/rxjs": {
+      "version": "7.8.2",
+      "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
+      "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/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/safe-regex2": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.1.tgz",
+      "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "ret": "~0.5.0"
+      },
+      "bin": {
+        "safe-regex2": "bin/safe-regex2.js"
+      }
+    },
+    "node_modules/safe-stable-stringify": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+      "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/secure-json-parse": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
+      "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+      "license": "MIT"
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/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.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shell-quote": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.9.0.tgz",
+      "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/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/sonic-boom": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz",
+      "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+      "license": "MIT",
+      "dependencies": {
+        "atomic-sleep": "^1.0.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/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/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "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/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/thread-stream": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz",
+      "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
+      "license": "MIT",
+      "dependencies": {
+        "real-require": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/thread-stream/node_modules/real-require": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
+      "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/toad-cache": {
+      "version": "3.7.4",
+      "resolved": "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.4.tgz",
+      "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tree-kill": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
+      "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "tree-kill": "cli.js"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD"
+    },
+    "node_modules/tsx": {
+      "version": "4.23.12",
+      "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz",
+      "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
+      "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/typescript": {
+      "version": "5.7.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz",
+      "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
+      "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
+      "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": "6.4.3",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
+      "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/android-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/android-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/android-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/win32-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite/node_modules/esbuild": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
+      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.12",
+        "@esbuild/android-arm": "0.25.12",
+        "@esbuild/android-arm64": "0.25.12",
+        "@esbuild/android-x64": "0.25.12",
+        "@esbuild/darwin-arm64": "0.25.12",
+        "@esbuild/darwin-x64": "0.25.12",
+        "@esbuild/freebsd-arm64": "0.25.12",
+        "@esbuild/freebsd-x64": "0.25.12",
+        "@esbuild/linux-arm": "0.25.12",
+        "@esbuild/linux-arm64": "0.25.12",
+        "@esbuild/linux-ia32": "0.25.12",
+        "@esbuild/linux-loong64": "0.25.12",
+        "@esbuild/linux-mips64el": "0.25.12",
+        "@esbuild/linux-ppc64": "0.25.12",
+        "@esbuild/linux-riscv64": "0.25.12",
+        "@esbuild/linux-s390x": "0.25.12",
+        "@esbuild/linux-x64": "0.25.12",
+        "@esbuild/netbsd-arm64": "0.25.12",
+        "@esbuild/netbsd-x64": "0.25.12",
+        "@esbuild/openbsd-arm64": "0.25.12",
+        "@esbuild/openbsd-x64": "0.25.12",
+        "@esbuild/openharmony-arm64": "0.25.12",
+        "@esbuild/sunos-x64": "0.25.12",
+        "@esbuild/win32-arm64": "0.25.12",
+        "@esbuild/win32-ia32": "0.25.12",
+        "@esbuild/win32-x64": "0.25.12"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/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": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "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/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/yargs": {
+      "version": "17.7.2",
+      "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
+      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^8.0.1",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.3",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^21.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "21.1.1",
+      "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
+      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/zod": {
+      "version": "3.25.76",
+      "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
+      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    }
+  }
+}

+ 36 - 0
package.json

@@ -0,0 +1,36 @@
+{
+  "name": "xinghen-course-workshop",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "concurrently -n web,server -c blue,cyan \"vite\" \"tsx watch server/index.ts\"",
+    "build": "tsc -b && vite build",
+    "start": "tsx server/index.ts",
+    "check": "tsc -b --pretty false",
+    "db:migrate": "tsx server/migrate.ts"
+  },
+  "dependencies": {
+    "@fastify/cors": "^10.0.2",
+    "@fastify/multipart": "^9.0.3",
+    "@fastify/static": "^8.1.1",
+    "fastify": "^5.2.1",
+    "lucide-react": "^0.468.0",
+    "pg": "^8.13.1",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "react-router-dom": "^7.1.1",
+    "zod": "^3.24.1"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.2",
+    "@types/pg": "^8.11.10",
+    "@types/react": "^19.0.2",
+    "@types/react-dom": "^19.0.2",
+    "@vitejs/plugin-react": "^4.3.4",
+    "concurrently": "^9.1.2",
+    "tsx": "^4.19.2",
+    "typescript": "~5.7.2",
+    "vite": "^6.0.5"
+  }
+}

BIN
sample/人物_坐.jpg


BIN
sample/人物_站.jpg


+ 77 - 0
server/db.ts

@@ -0,0 +1,77 @@
+import pg from 'pg'
+const { Pool } = pg
+
+export const pool = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgresql://xinghen:xinghen@localhost:5432/xinghen'
+})
+
+export const mapUser = (r: any) => ({
+  id: r.id,
+  phone: r.phone,
+  role: r.role || 'USER',
+  organization: r.organization || '',
+  wechat: r.wechat || '',
+  contactName: r.contact_name || '',
+  bio: r.bio || '',
+  createdAt: r.created_at,
+  updatedAt: r.updated_at
+})
+
+export const mapAsset = (r: any) => ({
+  id: r.id,
+  originalName: r.original_name,
+  size: Number(r.size),
+  mimeType: r.mime_type,
+  url: `/uploads/${r.stored_name}`,
+  createdAt: r.created_at
+})
+
+export const mapInstructor = (r: any) => ({
+  id: r.id,
+  name: r.name,
+  organization: r.organization || '',
+  introduction: r.introduction || '',
+  imageUrl: r.image_stored_name ? `/uploads/${r.image_stored_name}` : null
+})
+
+export const mapDeliverable = (r: any) => ({
+  id: r.id,
+  originalName: r.original_name,
+  size: Number(r.size),
+  mimeType: r.mime_type,
+  url: `/uploads/${r.stored_name}`,
+  createdAt: r.created_at
+})
+
+export const mapCourse = (
+  r: any,
+  assets: any[] = [],
+  instructors: any[] = [],
+  deliverables: any[] = [],
+  creatorUser: any = null
+) => ({
+  id: r.id,
+  userId: r.user_id,
+  name: r.name,
+  category: r.category || '',
+  audience: r.audience || '',
+  description: r.description || '',
+  status: r.status,
+  productionNotes: r.production_notes || '',
+  createdAt: r.created_at,
+  submittedAt: r.submitted_at || null,
+  completedAt: r.completed_at || null,
+  assets: assets.map(mapAsset),
+  instructors: instructors.map(mapInstructor),
+  deliverables: deliverables.map(mapDeliverable),
+  creator: creatorUser ? mapUser(creatorUser) : (r.user_phone ? {
+    id: r.user_id,
+    phone: r.user_phone,
+    role: r.user_role || 'USER',
+    organization: r.user_organization || '',
+    wechat: r.user_wechat || '',
+    contactName: r.user_contact_name || '',
+    bio: r.user_bio || '',
+    createdAt: r.user_created_at
+  } : undefined)
+})

+ 599 - 0
server/index.ts

@@ -0,0 +1,599 @@
+import Fastify from 'fastify'
+import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
+import cors from '@fastify/cors'
+import multipart from '@fastify/multipart'
+import staticPlugin from '@fastify/static'
+import { createWriteStream } from 'node:fs'
+import { mkdir, readFile, unlink } from 'node:fs/promises'
+import { pipeline } from 'node:stream/promises'
+import { resolve, extname, dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto'
+import { promisify } from 'node:util'
+import { z } from 'zod'
+import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
+
+const scrypt = promisify(scryptCb)
+const app = Fastify({ logger: true })
+const port = Number(process.env.PORT || 3001)
+const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
+const maxBytes = Number(process.env.MAX_UPLOAD_MB || 200) * 1024 * 1024
+
+await mkdir(uploadDir, { recursive: true })
+const here = dirname(fileURLToPath(import.meta.url))
+await pool.query(await readFile(join(here, 'schema.sql'), 'utf8'))
+
+// 检查是否存在管理员账号,若不存在则创建默认演示管理员
+async function initDefaultAdmin() {
+  try {
+    const { rows } = await pool.query("SELECT id FROM users WHERE role = 'ADMIN' LIMIT 1")
+    if (rows.length === 0) {
+      const defaultPhone = process.env.ADMIN_PHONE || '18888888888'
+      const defaultPassword = process.env.ADMIN_PASSWORD || 'admin123456'
+      const existing = await pool.query('SELECT id FROM users WHERE phone = $1', [defaultPhone])
+      const salt = randomBytes(16).toString('hex')
+      const key = (await scrypt(defaultPassword, salt, 64)) as Buffer
+      const hash = 'scrypt$' + salt + '$' + key.toString('hex')
+
+      if (existing.rows.length > 0) {
+        await pool.query("UPDATE users SET role = 'ADMIN' WHERE id = $1", [existing.rows[0].id])
+        app.log.info(`Updated existing user ${defaultPhone} to ADMIN role`)
+      } else {
+        const id = randomUUID()
+        await pool.query(
+          "INSERT INTO users(id, phone, password_hash, role, contact_name, organization, bio) VALUES($1, $2, $3, 'ADMIN', $4, $5, $6)",
+          [id, defaultPhone, hash, '系统管理员', '星痕课程工坊管理中心', '默认初始化管理员账号']
+        )
+        app.log.info(`Initialized default admin account: ${defaultPhone} / ${defaultPassword}`)
+      }
+    }
+  } catch (err) {
+    app.log.error(err, 'Failed to initialize default admin account')
+  }
+}
+await initDefaultAdmin()
+
+await app.register(cors, { origin: process.env.NODE_ENV === 'production' ? false : true })
+await app.register(multipart, { limits: { fileSize: maxBytes, files: 20, fields: 30 } })
+await app.register(staticPlugin, { root: uploadDir, prefix: '/uploads/', decorateReply: false })
+
+const sha = (v: string) => createHash('sha256').update(v).digest('hex')
+
+async function hashPassword(p: string) {
+  const salt = randomBytes(16).toString('hex')
+  const key = (await scrypt(p, salt, 64)) as Buffer
+  return 'scrypt$' + salt + '$' + key.toString('hex')
+}
+
+async function verifyPassword(p: string, stored: string) {
+  const [, salt, hex] = stored.split('$')
+  if (!salt || !hex) return false
+  const a = (await scrypt(p, salt, 64)) as Buffer
+  const b = Buffer.from(hex, 'hex')
+  return a.length === b.length && timingSafeEqual(a, b)
+}
+
+async function session(uid: string) {
+  const token = randomBytes(32).toString('hex')
+  await pool.query("INSERT INTO sessions(token_hash, user_id, expires_at) VALUES($1, $2, NOW() + INTERVAL '30 days')", [
+    sha(token),
+    uid
+  ])
+  return token
+}
+
+async function auth(req: FastifyRequest, reply: FastifyReply) {
+  const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
+  if (!token) return reply.code(401).send({ message: '请先登录' })
+  const { rows } = await pool.query(
+    'SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW()',
+    [sha(token)]
+  )
+  if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' })
+  ;(req as any).user = rows[0]
+}
+
+async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
+  await auth(req, reply)
+  if (reply.sent) return
+  const user = (req as any).user
+  if (!user || user.role !== 'ADMIN') {
+    return reply.code(403).send({ message: '权限不足:仅管理员可访问此接口' })
+  }
+}
+
+const userId = (r: any) => r.user.id
+const phonePassword = z.object({
+  phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
+  password: z.string().min(6, '密码至少 6 位').max(72)
+})
+
+const fullCourse = async (row: any, includeCreator = false) => {
+  const [a, i, d] = await Promise.all([
+    pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
+    pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
+    pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id])
+  ])
+  let creatorUser = null
+  if (includeCreator && row.user_id) {
+    const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
+    creatorUser = u.rows[0] || null
+  }
+  return mapCourse(row, a.rows, i.rows, d.rows, creatorUser)
+}
+
+const ownedCourse = async (id: string, uid: string) =>
+  (await pool.query('SELECT * FROM courses WHERE id = $1 AND user_id = $2', [id, uid])).rows[0]
+
+app.get('/api/health', async () => ({ ok: true }))
+
+// ==================== 认证相关 ====================
+
+app.post('/api/auth/register', async (req, reply) => {
+  const p = phonePassword.safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+  try {
+    const id = randomUUID()
+    const hash = await hashPassword(p.data.password)
+    const { rows } = await pool.query(
+      'INSERT INTO users(id, phone, password_hash, role) VALUES($1, $2, $3, $4) RETURNING *',
+      [id, p.data.phone, hash, 'USER']
+    )
+    return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
+  } catch (e: any) {
+    if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
+    throw e
+  }
+})
+
+app.post('/api/auth/login', async (req, reply) => {
+  const p = phonePassword.safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
+  const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
+  if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
+    return reply.code(401).send({ message: '手机号或密码错误' })
+  }
+  return { token: await session(rows[0].id), user: mapUser(rows[0]) }
+})
+
+app.post('/api/admin/auth/login', async (req, reply) => {
+  const p = phonePassword.safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '手机号或密码格式不正确' })
+  const { rows } = await pool.query('SELECT * FROM users WHERE phone = $1', [p.data.phone])
+  if (!rows[0] || !(await verifyPassword(p.data.password, rows[0].password_hash))) {
+    return reply.code(401).send({ message: '管理员账号或密码错误' })
+  }
+  if (rows[0].role !== 'ADMIN') {
+    return reply.code(403).send({ message: '该账号不是管理员,无权登录后台' })
+  }
+  return { token: await session(rows[0].id), user: mapUser(rows[0]) }
+})
+
+app.post('/api/auth/logout', { preHandler: auth }, async (req) => {
+  const token = req.headers.authorization!.replace(/^Bearer\s+/i, '')
+  await pool.query('DELETE FROM sessions WHERE token_hash = $1', [sha(token)])
+  return { ok: true }
+})
+
+app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) }))
+
+app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
+  const p = z
+    .object({
+      organization: z.string().max(120),
+      wechat: z.string().max(80),
+      contactName: z.string().max(80),
+      bio: z.string().max(500)
+    })
+    .safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '用户信息格式不正确' })
+  const d = p.data
+  const { rows } = await pool.query(
+    'UPDATE users SET organization = $1, wechat = $2, contact_name = $3, bio = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
+    [d.organization, d.wechat, d.contactName, d.bio, userId(req)]
+  )
+  return { user: mapUser(rows[0]) }
+})
+
+app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
+  const p = z
+    .object({
+      currentPassword: z.string(),
+      newPassword: z.string().min(6).max(72)
+    })
+    .safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '新密码至少 6 位' })
+  if (!(await verifyPassword(p.data.currentPassword, (req as any).user.password_hash))) {
+    return reply.code(400).send({ message: '当前密码不正确' })
+  }
+  await pool.query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [
+    await hashPassword(p.data.newPassword),
+    userId(req)
+  ])
+  return { ok: true }
+})
+
+// ==================== 普通用户课程操作 ====================
+
+const courseInput = z.object({
+  name: z.string().trim().min(1, '请输入课程名称').max(100),
+  category: z.string().max(50).default(''),
+  audience: z.string().max(100).default(''),
+  description: z.string().max(500).default('')
+})
+
+app.get('/api/courses', { preHandler: auth }, async (req) => {
+  const { rows } = await pool.query('SELECT * FROM courses WHERE user_id = $1 ORDER BY created_at DESC', [
+    userId(req)
+  ])
+  return { courses: await Promise.all(rows.map((r) => fullCourse(r))) }
+})
+
+app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
+  const p = courseInput.safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请完整填写课程信息' })
+  const id = randomUUID()
+  const d = p.data
+  const { rows } = await pool.query(
+    'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
+    [id, userId(req), d.name, d.category, d.audience, d.description]
+  )
+  return reply.code(201).send({ course: await fullCourse(rows[0]) })
+})
+
+app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  return { course: await fullCourse(row) }
+})
+
+app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可再上传资产' })
+  let count = 0
+  for await (const part of req.files()) {
+    const ext = extname(part.filename).slice(0, 16)
+    const stored = id + '-' + randomUUID() + ext
+    const target = resolve(uploadDir, stored)
+    await pipeline(part.file, createWriteStream(target))
+    if (part.file.truncated) {
+      await unlink(target).catch(() => {})
+      return reply.code(413).send({ message: '文件超过大小限制' })
+    }
+    await pool.query(
+      'INSERT INTO course_assets(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)',
+      [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype]
+    )
+    count++
+  }
+  if (!count) return reply.code(400).send({ message: '请选择课程资产文件' })
+  return { course: await fullCourse(row) }
+})
+
+app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
+  const { id, assetId } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可删除资产' })
+  const { rows } = await pool.query(
+    'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
+    [assetId, id]
+  )
+  if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
+  return { course: await fullCourse(row) }
+})
+
+app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
+  const fields: any = {}
+  let image: any = null
+  for await (const part of req.parts()) {
+    if (part.type === 'file') {
+      if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
+      const ext = extname(part.filename).slice(0, 12)
+      const stored = 'instructor-' + randomUUID() + ext
+      await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
+      image = { original: part.filename, stored, mime: part.mimetype }
+    } else {
+      fields[part.fieldname] = part.value
+    }
+  }
+  const p = z
+    .object({
+      name: z.string().trim().min(1, '请填写讲师姓名').max(80),
+      organization: z.string().max(120).default(''),
+      introduction: z.string().max(1000).default('')
+    })
+    .safeParse(fields)
+  if (!p.success) {
+    if (image) await unlink(resolve(uploadDir, image.stored)).catch(() => {})
+    return reply.code(400).send({ message: '请填写讲师姓名' })
+  }
+  const d = p.data
+  const { rows } = await pool.query(
+    'INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *',
+    [
+      randomUUID(),
+      id,
+      d.name,
+      d.organization,
+      d.introduction,
+      image?.original || null,
+      image?.stored || null,
+      image?.mime || null
+    ]
+  )
+  return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
+})
+
+app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, async (req, reply) => {
+  const { id, instructorId } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
+  const { rows } = await pool.query(
+    'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
+    [instructorId, id]
+  )
+  if (rows[0]?.image_stored_name) await unlink(resolve(uploadDir, rows[0].image_stored_name)).catch(() => {})
+  return { course: await fullCourse(row) }
+})
+
+app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const { rows } = await pool.query(
+    "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
+    [id, userId(req)]
+  )
+  if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
+  return { course: await fullCourse(rows[0]) }
+})
+
+// ==================== 管理员专属 ADMIN API ====================
+
+// 1. 统计概览数据
+app.get('/api/admin/stats', { preHandler: adminAuth }, async () => {
+  const [uRes, cRes, sRes] = await Promise.all([
+    pool.query('SELECT COUNT(*)::int AS count FROM users'),
+    pool.query('SELECT COUNT(*)::int AS count FROM courses'),
+    pool.query(`
+      SELECT
+        COUNT(*) FILTER (WHERE status = 'WAITING_PRODUCTION')::int AS waiting_count,
+        COUNT(*) FILTER (WHERE status = 'IN_PRODUCTION')::int AS in_production_count,
+        COUNT(*) FILTER (WHERE status = 'COMPLETED')::int AS completed_count,
+        COUNT(*) FILTER (WHERE status = 'DRAFT')::int AS draft_count
+      FROM courses
+    `)
+  ])
+
+  return {
+    totalUsers: uRes.rows[0].count,
+    totalCourses: cRes.rows[0].count,
+    waitingCount: sRes.rows[0].waiting_count || 0,
+    inProductionCount: sRes.rows[0].in_production_count || 0,
+    completedCount: sRes.rows[0].completed_count || 0,
+    draftCount: sRes.rows[0].draft_count || 0
+  }
+})
+
+// 2. 获取全量课程列表(支持状态筛选与搜索)
+app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
+  const query = (req.query || {}) as { status?: string; keyword?: string; page?: string; limit?: string }
+  const params: any[] = []
+  const conditions: string[] = []
+
+  if (query.status && query.status !== 'ALL') {
+    params.push(query.status)
+    conditions.push(`c.status = $${params.length}`)
+  }
+
+  if (query.keyword && query.keyword.trim()) {
+    params.push(`%${query.keyword.trim()}%`)
+    const idx = params.length
+    conditions.push(`(c.name ILIKE $${idx} OR c.category ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx} OR u.organization ILIKE $${idx})`)
+  }
+
+  const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
+  const sql = `
+    SELECT
+      c.*,
+      u.phone AS user_phone,
+      u.role AS user_role,
+      u.organization AS user_organization,
+      u.wechat AS user_wechat,
+      u.contact_name AS user_contact_name,
+      u.bio AS user_bio,
+      u.created_at AS user_created_at
+    FROM courses c
+    LEFT JOIN users u ON u.id = c.user_id
+    ${whereClause}
+    ORDER BY
+      CASE
+        WHEN c.status = 'WAITING_PRODUCTION' THEN 1
+        WHEN c.status = 'IN_PRODUCTION' THEN 2
+        WHEN c.status = 'DRAFT' THEN 3
+        WHEN c.status = 'COMPLETED' THEN 4
+        ELSE 5
+      END,
+      c.submitted_at DESC NULLS LAST,
+      c.created_at DESC
+  `
+
+  const { rows } = await pool.query(sql, params)
+  const courses = await Promise.all(rows.map((r) => fullCourse(r, true)))
+  return { courses, total: courses.length }
+})
+
+// 3. 获取特定课程完整详情(供制作工作台使用)
+app.get('/api/admin/courses/:id', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const sql = `
+    SELECT
+      c.*,
+      u.phone AS user_phone,
+      u.role AS user_role,
+      u.organization AS user_organization,
+      u.wechat AS user_wechat,
+      u.contact_name AS user_contact_name,
+      u.bio AS user_bio,
+      u.created_at AS user_created_at
+    FROM courses c
+    LEFT JOIN users u ON u.id = c.user_id
+    WHERE c.id = $1
+  `
+  const { rows } = await pool.query(sql, [id])
+  if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
+  const course = await fullCourse(rows[0], true)
+  return { course }
+})
+
+// 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
+app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const p = z.object({
+    status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
+  }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
+
+  const { rows } = await pool.query(
+    'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
+    [p.data.status, id]
+  )
+  if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
+  return { course: await fullCourse(rows[0], true) }
+})
+
+// 5. 管理员上传制作交付成品文件
+app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
+  if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
+
+  let count = 0
+  for await (const part of req.files()) {
+    const ext = extname(part.filename).slice(0, 16)
+    const stored = 'deliverable-' + id + '-' + randomUUID() + ext
+    const target = resolve(uploadDir, stored)
+    await pipeline(part.file, createWriteStream(target))
+    if (part.file.truncated) {
+      await unlink(target).catch(() => {})
+      return reply.code(413).send({ message: '文件超过大小限制' })
+    }
+    await pool.query(
+      'INSERT INTO course_deliverables(id, course_id, original_name, stored_name, size, mime_type) VALUES($1, $2, $3, $4, $5, $6)',
+      [randomUUID(), id, part.filename, stored, part.file.bytesRead, part.mimetype]
+    )
+    count++
+  }
+  if (!count) return reply.code(400).send({ message: '请选择交付成果文件' })
+  return { course: await fullCourse(check.rows[0], true) }
+})
+
+// 6. 删除已上传的交付成果文件
+app.delete('/api/admin/courses/:id/deliverables/:delivId', { preHandler: adminAuth }, async (req, reply) => {
+  const { id, delivId } = req.params as any
+  const check = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
+  if (!check.rows[0]) return reply.code(404).send({ message: '课程不存在' })
+
+  const { rows } = await pool.query(
+    'DELETE FROM course_deliverables WHERE id = $1 AND course_id = $2 RETURNING stored_name',
+    [delivId, id]
+  )
+  if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
+  return { course: await fullCourse(check.rows[0], true) }
+})
+
+// 7. 提交制作成果,结单交付(流转为 COMPLETED)
+app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const p = z.object({
+    productionNotes: z.string().max(2000).default('')
+  }).safeParse(req.body || {})
+  if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
+
+  const { rows } = await pool.query(
+    "UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING *",
+    [p.data.productionNotes, id]
+  )
+  if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
+  return { course: await fullCourse(rows[0], true) }
+})
+
+// 8. 获取全平台用户列表及统计
+app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
+  const query = (req.query || {}) as { keyword?: string }
+  const params: any[] = []
+  let where = ''
+  if (query.keyword && query.keyword.trim()) {
+    params.push(`%${query.keyword.trim()}%`)
+    where = 'WHERE (u.phone ILIKE $1 OR u.contact_name ILIKE $1 OR u.organization ILIKE $1 OR u.wechat ILIKE $1)'
+  }
+
+  const sql = `
+    SELECT
+      u.*,
+      COUNT(c.id)::int AS courses_count,
+      COUNT(c.id) FILTER (WHERE c.status = 'WAITING_PRODUCTION')::int AS waiting_courses_count,
+      COUNT(c.id) FILTER (WHERE c.status = 'IN_PRODUCTION')::int AS in_production_courses_count,
+      COUNT(c.id) FILTER (WHERE c.status = 'COMPLETED')::int AS completed_courses_count
+    FROM users u
+    LEFT JOIN courses c ON c.user_id = u.id
+    ${where}
+    GROUP BY u.id
+    ORDER BY u.created_at DESC
+  `
+  const { rows } = await pool.query(sql, params)
+  const users = rows.map((r) => ({
+    ...mapUser(r),
+    coursesCount: r.courses_count || 0,
+    waitingCoursesCount: r.waiting_courses_count || 0,
+    inProductionCoursesCount: r.in_production_courses_count || 0,
+    completedCoursesCount: r.completed_courses_count || 0
+  }))
+  return { users }
+})
+
+// 9. 修改用户角色(赋予或撤销管理员权限)
+app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const currentAdmin = (req as any).user
+  const p = z.object({
+    role: z.enum(['USER', 'ADMIN'])
+  }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '角色类型不正确' })
+
+  if (id === currentAdmin.id && p.data.role !== 'ADMIN') {
+    return reply.code(400).send({ message: '不能撤销当前登录账号的管理员权限' })
+  }
+
+  const { rows } = await pool.query(
+    'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
+    [p.data.role, id]
+  )
+  if (!rows[0]) return reply.code(404).send({ message: '用户不存在' })
+  return { user: mapUser(rows[0]) }
+})
+
+// ==================== 静态托管与错误处理 ====================
+
+if (process.env.NODE_ENV === 'production') {
+  const dist = resolve('./dist')
+  await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false })
+  app.setNotFoundHandler((req, reply) =>
+    req.url.startsWith('/api/') ? reply.code(404).send({ message: '接口不存在' }) : reply.sendFile('index.html')
+  )
+}
+
+app.setErrorHandler((error: FastifyError, _req, reply) => {
+  app.log.error(error)
+  const status = error.statusCode && error.statusCode < 500 ? error.statusCode : 500
+  reply.code(status).send({ message: status === 500 ? '服务暂时不可用,请稍后重试' : error.message })
+})
+
+await app.listen({ port, host: process.env.HOST || '0.0.0.0' })

+ 10 - 0
server/migrate.ts

@@ -0,0 +1,10 @@
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { dirname, join } from 'node:path'
+import { pool } from './db.js'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const sql = await readFile(join(here, 'schema.sql'), 'utf8')
+await pool.query(sql)
+console.log('Database schema is ready.')
+await pool.end()

+ 91 - 0
server/schema.sql

@@ -0,0 +1,91 @@
+CREATE TABLE IF NOT EXISTS users (
+  id UUID PRIMARY KEY,
+  phone VARCHAR(20) UNIQUE NOT NULL,
+  password_hash TEXT NOT NULL,
+  role VARCHAR(20) NOT NULL DEFAULT 'USER',
+  organization VARCHAR(120) NOT NULL DEFAULT '',
+  wechat VARCHAR(80) NOT NULL DEFAULT '',
+  contact_name VARCHAR(80) NOT NULL DEFAULT '',
+  bio VARCHAR(500) NOT NULL DEFAULT '',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'USER';
+
+CREATE TABLE IF NOT EXISTS sessions (
+  token_hash CHAR(64) PRIMARY KEY,
+  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+  expires_at TIMESTAMPTZ NOT NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
+
+CREATE TABLE IF NOT EXISTS courses (
+  id UUID PRIMARY KEY,
+  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
+  name VARCHAR(100) NOT NULL,
+  category VARCHAR(50) NOT NULL DEFAULT '',
+  audience VARCHAR(100) NOT NULL DEFAULT '',
+  description VARCHAR(500) NOT NULL DEFAULT '',
+  status VARCHAR(30) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED')),
+  production_notes TEXT NOT NULL DEFAULT '',
+  ppt_original_name VARCHAR(255),
+  ppt_stored_name VARCHAR(255),
+  ppt_size BIGINT,
+  ppt_mime_type VARCHAR(150),
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  submitted_at TIMESTAMPTZ,
+  completed_at TIMESTAMPTZ
+);
+
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS production_notes TEXT NOT NULL DEFAULT '';
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
+
+DO $$
+BEGIN
+  ALTER TABLE courses DROP CONSTRAINT IF EXISTS courses_status_check;
+  ALTER TABLE courses ADD CONSTRAINT courses_status_check CHECK (status IN ('DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'));
+EXCEPTION
+  WHEN OTHERS THEN NULL;
+END $$;
+
+CREATE INDEX IF NOT EXISTS idx_courses_user_created ON courses(user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_courses_status_created ON courses(status, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS course_assets (
+  id UUID PRIMARY KEY,
+  course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  original_name VARCHAR(255) NOT NULL,
+  stored_name VARCHAR(255) NOT NULL,
+  size BIGINT NOT NULL,
+  mime_type VARCHAR(150) NOT NULL DEFAULT 'application/octet-stream',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_assets_course ON course_assets(course_id, created_at);
+
+CREATE TABLE IF NOT EXISTS instructors (
+  id UUID PRIMARY KEY,
+  course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  name VARCHAR(80) NOT NULL,
+  organization VARCHAR(120) NOT NULL DEFAULT '',
+  introduction VARCHAR(1000) NOT NULL DEFAULT '',
+  image_original_name VARCHAR(255),
+  image_stored_name VARCHAR(255),
+  image_mime_type VARCHAR(100),
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_instructors_course ON instructors(course_id, created_at);
+
+CREATE TABLE IF NOT EXISTS course_deliverables (
+  id UUID PRIMARY KEY,
+  course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  original_name VARCHAR(255) NOT NULL,
+  stored_name VARCHAR(255) NOT NULL,
+  size BIGINT NOT NULL,
+  mime_type VARCHAR(150) NOT NULL DEFAULT 'application/octet-stream',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_deliverables_course ON course_deliverables(course_id, created_at);

+ 48 - 0
src/App.tsx

@@ -0,0 +1,48 @@
+import { Navigate, Route, Routes } from 'react-router-dom'
+import { Layout } from './components/Layout'
+import { Dashboard } from './pages/Dashboard'
+import { NewCourse } from './pages/NewCourse'
+import { CourseWorkspace } from './pages/CourseWorkspace'
+import { AuthPage } from './pages/AuthPage'
+import { Profile } from './pages/Profile'
+import { AdminLayout } from './pages/admin/AdminLayout'
+import { AdminDashboard } from './pages/admin/AdminDashboard'
+import { AdminCourses } from './pages/admin/AdminCourses'
+import { AdminCourseDetail } from './pages/admin/AdminCourseDetail'
+import { AdminUsers } from './pages/admin/AdminUsers'
+import { AdminLoginPage } from './pages/admin/AdminLoginPage'
+import { useAuth } from './AuthContext'
+
+export default function App() {
+  const { user, loading } = useAuth()
+
+  if (loading) return <div className="center-loading">正在加载星痕工坊…</div>
+
+  return (
+    <Routes>
+      {/* ================= 管理员专属体系 (/admin/*) ================= */}
+      <Route path="admin/login" element={<AdminLoginPage />} />
+      <Route path="admin" element={<AdminLayout />}>
+        <Route index element={<AdminDashboard />} />
+        <Route path="courses" element={<AdminCourses />} />
+        <Route path="courses/:id" element={<AdminCourseDetail />} />
+        <Route path="users" element={<AdminUsers />} />
+        <Route path="*" element={<Navigate to="/admin" replace />} />
+      </Route>
+
+      {/* ================= 普通用户体系 ================= */}
+      <Route path="login" element={<AuthPage />} />
+      {user ? (
+        <Route element={<Layout />}>
+          <Route index element={<Dashboard />} />
+          <Route path="courses/new" element={<NewCourse />} />
+          <Route path="courses/:id" element={<CourseWorkspace />} />
+          <Route path="profile" element={<Profile />} />
+        </Route>
+      ) : null}
+
+      {/* 默认兜底重定向 */}
+      <Route path="*" element={<Navigate to={user ? '/' : '/login'} replace />} />
+    </Routes>
+  )
+}

+ 61 - 0
src/AuthContext.tsx

@@ -0,0 +1,61 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+import { api } from './api'
+import type { User } from './types'
+
+type AuthContextType = {
+  user: User | null
+  loading: boolean
+  authenticate: (mode: 'login' | 'register', phone: string, password: string) => Promise<void>
+  adminLogin: (phone: string, password: string) => Promise<User>
+  logout: () => Promise<void>
+  setUser: (u: User) => void
+}
+
+const AuthContext = createContext<AuthContextType>(null!)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const [user, setUser] = useState<User | null>(null)
+  const [loading, setLoading] = useState(true)
+
+  useEffect(() => {
+    const load = () =>
+      localStorage.getItem('xinghen_token')
+        ? api
+            .me()
+            .then((r) => setUser(r.user))
+            .catch(() => setUser(null))
+            .finally(() => setLoading(false))
+        : setLoading(false)
+
+    load()
+    addEventListener('auth-expired', load)
+    return () => removeEventListener('auth-expired', load)
+  }, [])
+
+  async function authenticate(mode: 'login' | 'register', phone: string, password: string) {
+    const r = await api[mode](phone, password)
+    localStorage.setItem('xinghen_token', r.token)
+    setUser(r.user)
+  }
+
+  async function adminLogin(phone: string, password: string) {
+    const r = await api.admin.login(phone, password)
+    localStorage.setItem('xinghen_token', r.token)
+    setUser(r.user)
+    return r.user
+  }
+
+  async function logout() {
+    await api.logout().catch(() => {})
+    localStorage.removeItem('xinghen_token')
+    setUser(null)
+  }
+
+  return (
+    <AuthContext.Provider value={{ user, loading, authenticate, adminLogin, logout, setUser }}>
+      {children}
+    </AuthContext.Provider>
+  )
+}
+
+export const useAuth = () => useContext(AuthContext)

+ 127 - 0
src/api.ts

@@ -0,0 +1,127 @@
+import type { AdminStats, Course, CourseStatus, User } from './types'
+
+const token = () => localStorage.getItem('xinghen_token')
+
+async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
+  const headers = new Headers(init.headers)
+  if (token()) headers.set('Authorization', `Bearer ${token()}`)
+  const response = await fetch(`/api${path}`, { ...init, headers })
+  if (!response.ok) {
+    const body = await response.json().catch(() => ({ message: '请求失败,请稍后重试' }))
+    if (response.status === 401) {
+      localStorage.removeItem('xinghen_token')
+      window.dispatchEvent(new Event('auth-expired'))
+    }
+    throw new Error(body.message || '请求失败,请稍后重试')
+  }
+  return response.json() as Promise<T>
+}
+
+export const api = {
+  // 用户鉴权与信息
+  register: (phone: string, password: string) =>
+    request<{ token: string; user: User }>('/auth/register', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ phone, password })
+    }),
+  login: (phone: string, password: string) =>
+    request<{ token: string; user: User }>('/auth/login', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ phone, password })
+    }),
+  logout: () => request<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
+  me: () => request<{ user: User }>('/me'),
+  updateMe: (body: Pick<User, 'organization' | 'wechat' | 'contactName' | 'bio'>) =>
+    request<{ user: User }>('/me', {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body)
+    }),
+  changePassword: (currentPassword: string, newPassword: string) =>
+    request<{ ok: boolean }>('/me/password', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ currentPassword, newPassword })
+    }),
+
+  // 普通用户课程操作
+  listCourses: () => request<{ courses: Course[] }>('/courses'),
+  createCourse: (body: Pick<Course, 'name' | 'category' | 'audience' | 'description'>) =>
+    request<{ course: Course }>('/courses', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body)
+    }),
+  getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`),
+  uploadAssets: (id: string, files: File[]) => {
+    const form = new FormData()
+    files.forEach((f) => form.append('assets', f))
+    return request<{ course: Course }>(`/courses/${id}/assets`, { method: 'POST', body: form })
+  },
+  deleteAsset: (id: string, assetId: string) =>
+    request<{ course: Course }>(`/courses/${id}/assets/${assetId}`, { method: 'DELETE' }),
+  addInstructor: (
+    id: string,
+    data: { name: string; organization: string; introduction: string; image?: File }
+  ) => {
+    const f = new FormData()
+    f.append('name', data.name)
+    f.append('organization', data.organization)
+    f.append('introduction', data.introduction)
+    if (data.image) f.append('image', data.image)
+    return request<{ course: Course }>(`/courses/${id}/instructors`, { method: 'POST', body: f })
+  },
+  deleteInstructor: (id: string, instructorId: string) =>
+    request<{ course: Course }>(`/courses/${id}/instructors/${instructorId}`, { method: 'DELETE' }),
+  submitCourse: (id: string) => request<{ course: Course }>(`/courses/${id}/submit`, { method: 'POST' }),
+
+  // 管理员后台专区 API
+  admin: {
+    login: (phone: string, password: string) =>
+      request<{ token: string; user: User }>('/admin/auth/login', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ phone, password })
+      }),
+    getStats: () => request<AdminStats>('/admin/stats'),
+    listCourses: (params?: { status?: string; keyword?: string }) => {
+      const q = new URLSearchParams()
+      if (params?.status) q.set('status', params.status)
+      if (params?.keyword) q.set('keyword', params.keyword)
+      const qs = q.toString() ? `?${q.toString()}` : ''
+      return request<{ courses: Course[]; total: number }>(`/admin/courses${qs}`)
+    },
+    getCourse: (id: string) => request<{ course: Course }>(`/admin/courses/${id}`),
+    updateStatus: (id: string, status: CourseStatus) =>
+      request<{ course: Course }>(`/admin/courses/${id}/status`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ status })
+      }),
+    uploadDeliverables: (id: string, files: File[]) => {
+      const form = new FormData()
+      files.forEach((f) => form.append('deliverables', f))
+      return request<{ course: Course }>(`/admin/courses/${id}/deliverables`, { method: 'POST', body: form })
+    },
+    deleteDeliverable: (id: string, delivId: string) =>
+      request<{ course: Course }>(`/admin/courses/${id}/deliverables/${delivId}`, { method: 'DELETE' }),
+    completeCourse: (id: string, productionNotes: string) =>
+      request<{ course: Course }>(`/admin/courses/${id}/complete`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ productionNotes })
+      }),
+    listUsers: (keyword?: string) => {
+      const qs = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''
+      return request<{ users: User[] }>(`/admin/users${qs}`)
+    },
+    updateUserRole: (id: string, role: 'USER' | 'ADMIN') =>
+      request<{ user: User }>(`/admin/users/${id}/role`, {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ role })
+      })
+  }
+}

+ 6 - 0
src/components/Brand.tsx

@@ -0,0 +1,6 @@
+export function Brand({ compact = false }: { compact?: boolean }) {
+  return <div className={`brand ${compact ? 'brand--compact' : ''}`} aria-label="星痕智能课程工坊">
+    <span className="brand-mark" aria-hidden="true"><i /><b /></span>
+    <span><strong>星痕</strong>{!compact && <small>XINGHEN</small>}</span>
+  </div>
+}

+ 38 - 0
src/components/Layout.tsx

@@ -0,0 +1,38 @@
+import { CircleHelp, LogOut, UserRound } from 'lucide-react'
+import { NavLink, Outlet } from 'react-router-dom'
+import { Brand } from './Brand'
+import { useAuth } from '../AuthContext'
+
+export function Layout() {
+  const { user, logout } = useAuth()
+
+  return (
+    <div className="app-shell">
+      <header className="topbar">
+        <NavLink to="/" className="logo-link">
+          <Brand />
+        </NavLink>
+        <nav>
+          <NavLink to="/" end>
+            我的课程
+          </NavLink>
+        </nav>
+        <div className="top-actions">
+          <NavLink to="/profile" className="user-link">
+            <UserRound size={16} />
+            <span>{user?.contactName || user?.phone}</span>
+          </NavLink>
+          <button className="icon-button" title="帮助">
+            <CircleHelp size={19} />
+          </button>
+          <button className="icon-button" title="退出登录" onClick={logout}>
+            <LogOut size={18} />
+          </button>
+        </div>
+      </header>
+      <main>
+        <Outlet />
+      </main>
+    </div>
+  )
+}

+ 19 - 0
src/components/StatusBadge.tsx

@@ -0,0 +1,19 @@
+import type { CourseStatus } from '../types'
+
+const statusMap: Record<CourseStatus, { label: string; className: string }> = {
+  DRAFT: { label: '准备素材', className: 'status--draft' },
+  WAITING_PRODUCTION: { label: '等待制作', className: 'status--waiting_production' },
+  IN_PRODUCTION: { label: '制作中', className: 'status--in_production' },
+  COMPLETED: { label: '制作完成', className: 'status--completed' },
+  REJECTED: { label: '已驳回', className: 'status--rejected' }
+}
+
+export function StatusBadge({ status }: { status: CourseStatus }) {
+  const current = statusMap[status] || { label: status, className: 'status--unknown' }
+  return (
+    <span className={`status ${current.className}`}>
+      <i />
+      {current.label}
+    </span>
+  )
+}

+ 10 - 0
src/main.tsx

@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import App from './App'
+import { AuthProvider } from './AuthContext'
+import './styles.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+  <React.StrictMode><BrowserRouter><AuthProvider><App /></AuthProvider></BrowserRouter></React.StrictMode>,
+)

+ 2 - 0
src/pages/AuthPage.tsx

@@ -0,0 +1,2 @@
+import{FormEvent,useState}from'react';import{Navigate}from'react-router-dom';import{ArrowRight,LockKeyhole,Phone}from'lucide-react';import{Brand}from'../components/Brand';import{useAuth}from'../AuthContext'
+export function AuthPage(){const{user,authenticate}=useAuth(),[mode,setMode]=useState<'login'|'register'>('login'),[busy,setBusy]=useState(false),[error,setError]=useState('');if(user)return <Navigate to="/" replace/>;async function submit(e:FormEvent<HTMLFormElement>){e.preventDefault();const f=new FormData(e.currentTarget);setBusy(true);setError('');try{await authenticate(mode,String(f.get('phone')),String(f.get('password')))}catch(x){setError(x instanceof Error?x.message:'操作失败')}finally{setBusy(false)}}return <div className="auth-page"><section className="auth-brand"><Brand/><div><p className="eyebrow">智能课程工坊</p><h1>让每一门好课,<br/><em>留下清晰的星痕。</em></h1><p>归集课程资产与讲师信息,让课程制作更简单。</p></div></section><form className="auth-card" onSubmit={submit}><span className="section-kicker">WELCOME TO XINGHEN</span><h2>{mode==='login'?'登录星痕':'创建账号'}</h2><p>{mode==='login'?'使用手机号和密码继续':'注册后即可开始'}</p><label>手机号<div className="input-with-icon"><Phone/><input name="phone" inputMode="numeric" required pattern="1[0-9]{10}" placeholder="请输入 11 位手机号"/></div></label><label>密码<div className="input-with-icon"><LockKeyhole/><input name="password" type="password" required minLength={6} placeholder="至少 6 位密码"/></div></label>{error&&<div className="notice notice-error">{error}</div>}<button className="primary-button full-button" disabled={busy}>{busy?'请稍候…':mode==='login'?'登录':'注册'}<ArrowRight size={17}/></button><button type="button" className="auth-switch" onClick={()=>{setMode(mode==='login'?'register':'login');setError('')}}>{mode==='login'?'还没有账号?立即注册':'已有账号?返回登录'}</button></form></div>}

+ 371 - 0
src/pages/CourseWorkspace.tsx

@@ -0,0 +1,371 @@
+import {
+  ArrowLeft,
+  CheckCircle2,
+  Download,
+  FileCheck,
+  FilePlus2,
+  FileText,
+  LoaderCircle,
+  PackageCheck,
+  Send,
+  Sparkles,
+  Trash2,
+  UserPlus,
+  X
+} from 'lucide-react'
+import { FormEvent, useEffect, useRef, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { api } from '../api'
+import { StatusBadge } from '../components/StatusBadge'
+import type { Course } from '../types'
+
+const size = (n: number) =>
+  n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
+
+export function CourseWorkspace() {
+  const { id = '' } = useParams()
+  const input = useRef<HTMLInputElement>(null)
+  const [course, setCourse] = useState<Course | null>(null)
+  const [files, setFiles] = useState<File[]>([])
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+  const [showInstructor, setShowInstructor] = useState(false)
+
+  useEffect(() => {
+    api
+      .getCourse(id)
+      .then((r) => setCourse(r.course))
+      .catch((e) => setError(e.message))
+  }, [id])
+
+  async function upload() {
+    if (!files.length) return
+    setBusy(true)
+    try {
+      setCourse((await api.uploadAssets(id, files)).course)
+      setFiles([])
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function addInstructor(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const form = e.currentTarget
+    const f = new FormData(form)
+    const image = form.elements.namedItem('image') as HTMLInputElement
+    setBusy(true)
+    try {
+      setCourse(
+        (
+          await api.addInstructor(id, {
+            name: String(f.get('name')),
+            organization: String(f.get('organization')),
+            introduction: String(f.get('introduction')),
+            image: image.files?.[0]
+          })
+        ).course
+      )
+      form.reset()
+      setShowInstructor(false)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function submit() {
+    setBusy(true)
+    try {
+      setCourse((await api.submitCourse(id)).course)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  if (!course) return <div className="center-loading">{error || <LoaderCircle className="spin" />}</div>
+
+  const isWaiting = course.status === 'WAITING_PRODUCTION'
+  const isProducing = course.status === 'IN_PRODUCTION'
+  const isCompleted = course.status === 'COMPLETED'
+
+  return (
+    <div className="page workspace-page">
+      <div className="workspace-head">
+        <div>
+          <Link to="/" className="back-link">
+            <ArrowLeft size={16} /> 返回我的课程
+          </Link>
+          <div className="title-row">
+            <h1>{course.name}</h1>
+            <StatusBadge status={course.status} />
+          </div>
+          <p>
+            {course.category || '综合分类'}{course.audience ? ` · ${course.audience}` : ''}
+          </p>
+        </div>
+      </div>
+
+      {/* 制作完成交付成果面板 */}
+      {isCompleted && (
+        <section className="success-panel completed-panel">
+          <div className="success-badge-icon">
+            <PackageCheck size={48} />
+          </div>
+          <p className="eyebrow">
+            <Sparkles size={14} /> PRODUCTION COMPLETED
+          </p>
+          <h2>课程制作已完成!</h2>
+          <p>制作专家已完成课程打磨与成片制作,请查收下方交付成果及制作说明。</p>
+
+          {course.productionNotes && (
+            <div className="production-notes-box">
+              <strong>制作交付说明:</strong>
+              <p>{course.productionNotes}</p>
+            </div>
+          )}
+
+          <div className="deliverables-box">
+            <div className="box-header">
+              <FileCheck size={18} />
+              <h4>交付成品文件 ({course.deliverables?.length || 0})</h4>
+            </div>
+            {course.deliverables && course.deliverables.length > 0 ? (
+              <div className="deliverable-list">
+                {course.deliverables.map((deliv) => (
+                  <div className="deliverable-row" key={deliv.id}>
+                    <FileText size={20} />
+                    <div className="deliv-info">
+                      <strong>{deliv.originalName}</strong>
+                      <span>{size(deliv.size)}</span>
+                    </div>
+                    <a
+                      href={deliv.url}
+                      download={deliv.originalName}
+                      className="primary-button small-button"
+                      target="_blank"
+                      rel="noreferrer"
+                    >
+                      <Download size={14} /> 下载
+                    </a>
+                  </div>
+                ))}
+              </div>
+            ) : (
+              <p className="empty-tip">未附加文件附件,请参考上方交付说明中的链接或信息。</p>
+            )}
+          </div>
+
+          <div className="receipt">
+            <span>
+              <small>课程状态</small>
+              <strong style={{ color: '#2e7d32' }}>已完成交付</strong>
+            </span>
+            <span>
+              <small>提交时间</small>
+              <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
+            </span>
+            <span>
+              <small>交付时间</small>
+              <strong>{course.completedAt ? new Date(course.completedAt).toLocaleString('zh-CN') : '—'}</strong>
+            </span>
+          </div>
+
+          <Link className="secondary-button" to="/">
+            返回课程列表
+          </Link>
+        </section>
+      )}
+
+      {/* 正在制作中面板 */}
+      {isProducing && (
+        <section className="success-panel producing-panel">
+          <div className="producing-orbit-icon">
+            <LoaderCircle className="spin" size={48} />
+          </div>
+          <p className="eyebrow">IN PRODUCTION</p>
+          <h2>课程正在精心制作中</h2>
+          <p>
+            平台课程制作师已接单并正在处理您的课件素材与讲师信息,制作完成后将第一时间在此处交付成品。
+          </p>
+          <div className="receipt">
+            <span>
+              <small>课程状态</small>
+              <strong style={{ color: '#245bd6' }}>制作中</strong>
+            </span>
+            <span>
+              <small>课程资产</small>
+              <strong>{course.assets.length} 个文件</strong>
+            </span>
+            <span>
+              <small>提交时间</small>
+              <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
+            </span>
+          </div>
+          <Link className="secondary-button" to="/">
+            返回课程列表
+          </Link>
+        </section>
+      )}
+
+      {/* 等待制作面板 */}
+      {isWaiting && (
+        <section className="success-panel">
+          <CheckCircle2 size={48} />
+          <p className="eyebrow">SUBMISSION RECEIVED</p>
+          <h2>课程已提交,等待排期制作</h2>
+          <p>已收录 {course.assets.length} 份课程资产和 {course.instructors.length} 位讲师信息,制作团队即将接单。</p>
+          <div className="receipt">
+            <span>
+              <small>课程状态</small>
+              <strong>等待制作</strong>
+            </span>
+            <span>
+              <small>课程资产</small>
+              <strong>{course.assets.length} 个文件</strong>
+            </span>
+            <span>
+              <small>提交时间</small>
+              <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
+            </span>
+          </div>
+          <Link className="secondary-button" to="/">
+            返回课程列表
+          </Link>
+        </section>
+      )}
+
+      {/* 草稿阶段素材上传与讲师编辑 */}
+      {!isWaiting && !isProducing && !isCompleted && (
+        <div className="asset-layout">
+          <section className="upload-panel">
+            <div className="panel-heading">
+              <span className="step-number">02</span>
+              <div>
+                <p className="eyebrow">课程内容</p>
+                <h2>课程资产</h2>
+                <p>可上传 PPT、PDF、Word、TXT、图片、音视频等多个文件,也可以跳过。</p>
+              </div>
+            </div>
+            <div className="asset-list">
+              {course.assets.map((a) => (
+                <div className="asset-row" key={a.id}>
+                  <FileText />
+                  <div>
+                    <strong>{a.originalName}</strong>
+                    <span>{size(a.size)}</span>
+                  </div>
+                  <button
+                    title="删除"
+                    onClick={async () => setCourse((await api.deleteAsset(id, a.id)).course)}
+                  >
+                    <Trash2 size={16} />
+                  </button>
+                </div>
+              ))}
+            </div>
+            <div className="drop-zone compact" onClick={() => input.current?.click()}>
+              <FilePlus2 />
+              <h3>选择课程资产</h3>
+              <small>支持任意常见课程文件,单个文件不超过 200 MB</small>
+            </div>
+            <input
+              hidden
+              multiple
+              ref={input}
+              type="file"
+              onChange={(e) => setFiles(Array.from(e.target.files || []))}
+            />
+            {files.length > 0 && (
+              <div className="selected-stack">
+                {files.map((f, i) => (
+                  <div className="selected-file" key={i}>
+                    <FileText />
+                    <div>
+                      <strong>{f.name}</strong>
+                      <span>{size(f.size)}</span>
+                    </div>
+                    <button onClick={() => setFiles(files.filter((_, x) => x !== i))}>
+                      <X size={16} />
+                    </button>
+                  </div>
+                ))}
+                <button className="primary-button full-button" disabled={busy} onClick={upload}>
+                  {busy ? '正在上传…' : `上传 ${files.length} 个文件`}
+                </button>
+              </div>
+            )}
+          </section>
+          <aside className="instructor-panel">
+            <div className="aside-title">
+              <div>
+                <span className="section-kicker">INSTRUCTORS</span>
+                <h3>讲师信息</h3>
+              </div>
+              <button onClick={() => setShowInstructor(!showInstructor)}>
+                <UserPlus size={16} />
+                添加
+              </button>
+            </div>
+            {showInstructor && (
+              <form className="instructor-form" onSubmit={addInstructor}>
+                <label>
+                  讲师姓名 *<input name="name" required />
+                </label>
+                <label>
+                  单位
+                  <input name="organization" />
+                </label>
+                <label>
+                  讲师介绍
+                  <textarea name="introduction" rows={3} />
+                </label>
+                <label>
+                  讲师图片
+                  <input name="image" type="file" accept="image/*" />
+                </label>
+                <button className="primary-button full-button" disabled={busy}>
+                  保存讲师
+                </button>
+              </form>
+            )}
+            <div className="instructor-list">
+              {course.instructors.map((t) => (
+                <div className="instructor-card" key={t.id}>
+                  {t.imageUrl ? <img src={t.imageUrl} alt={t.name} /> : <span className="avatar">{t.name[0]}</span>}
+                  <div>
+                    <strong>{t.name}</strong>
+                    <small>{t.organization || '未填写单位'}</small>
+                    <p>{t.introduction || '暂无介绍'}</p>
+                  </div>
+                  <button
+                    onClick={async () => setCourse((await api.deleteInstructor(id, t.id)).course)}
+                  >
+                    <Trash2 size={14} />
+                  </button>
+                </div>
+              ))}
+              {!course.instructors.length && !showInstructor && (
+                <p className="empty-tip">还没有讲师,可按需添加</p>
+              )}
+            </div>
+            <div className="submit-box">
+              <p>课程资产和讲师信息均为可选项,可以稍后补充或直接提交。</p>
+              <button className="primary-button full-button" onClick={submit} disabled={busy}>
+                <Send size={16} />
+                提交制作
+              </button>
+            </div>
+          </aside>
+        </div>
+      )}
+
+      {error && <div className="notice notice-error">{error}</div>}
+    </div>
+  )
+}

+ 130 - 0
src/pages/Dashboard.tsx

@@ -0,0 +1,130 @@
+import { ArrowRight, BookOpen, CheckCircle2, Clock3, Loader2, Plus, Sparkles, Stars } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../api'
+import { StatusBadge } from '../components/StatusBadge'
+import type { Course } from '../types'
+
+export function Dashboard() {
+  const [courses, setCourses] = useState<Course[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    api
+      .listCourses()
+      .then((r) => setCourses(r.courses))
+      .catch((e) => setError(e.message))
+      .finally(() => setLoading(false))
+  }, [])
+
+  const waitingCount = courses.filter((c) => c.status === 'WAITING_PRODUCTION').length
+  const inProductionCount = courses.filter((c) => c.status === 'IN_PRODUCTION').length
+  const completedCount = courses.filter((c) => c.status === 'COMPLETED').length
+
+  return (
+    <div className="page dashboard">
+      <section className="hero">
+        <div className="orbit orbit-one" />
+        <div className="orbit orbit-two" />
+        <div className="star-dot star-a" />
+        <div className="hero-copy">
+          <p className="eyebrow">
+            <Stars size={15} /> 智能课程工坊
+          </p>
+          <h1>
+            让每一门好课,
+            <br />
+            <em>留下清晰的星痕。</em>
+          </h1>
+          <p>归集课程素材,建立制作任务。简单几步,让创意从这里启程。</p>
+          <Link className="primary-button" to="/courses/new">
+            <Plus size={18} /> 创建新课程
+          </Link>
+        </div>
+        <div className="hero-note">
+          <span>LESS AND COSMOS</span>
+          <p>删去繁复,只留下课程制作真正需要的部分。</p>
+        </div>
+      </section>
+
+      <section className="content-section">
+        <div className="section-heading">
+          <div>
+            <span className="section-kicker">WORKSPACE</span>
+            <h2>我的课程</h2>
+          </div>
+          <div className="metrics">
+            <span>
+              <BookOpen size={16} /> {courses.length} 门课程
+            </span>
+            {waitingCount > 0 && (
+              <span>
+                <Clock3 size={16} /> {waitingCount} 等待制作
+              </span>
+            )}
+            {inProductionCount > 0 && (
+              <span style={{ color: '#2d62ce' }}>
+                <Loader2 size={16} className="spin" /> {inProductionCount} 制作中
+              </span>
+            )}
+            {completedCount > 0 && (
+              <span style={{ color: '#2e7d32' }}>
+                <CheckCircle2 size={16} /> {completedCount} 已完成
+              </span>
+            )}
+          </div>
+        </div>
+
+        {error && <div className="notice notice-error">{error}</div>}
+
+        {loading ? (
+          <div className="course-grid">
+            <div className="skeleton-card" />
+            <div className="skeleton-card" />
+          </div>
+        ) : courses.length === 0 ? (
+          <div className="empty-state">
+            <span className="empty-orbit">
+              <i />
+            </span>
+            <h3>还没有课程</h3>
+            <p>创建第一门课程,上传课件素材后即可提交制作。</p>
+            <Link to="/courses/new">
+              开始创建 <ArrowRight size={16} />
+            </Link>
+          </div>
+        ) : (
+          <div className="course-grid">
+            {courses.map((course, index) => (
+              <Link to={`/courses/${course.id}`} className="course-card" key={course.id}>
+                <div className={`course-number tone-${index % 3}`}>
+                  {String(index + 1).padStart(2, '0')}
+                </div>
+                <div className="course-card-main">
+                  <StatusBadge status={course.status} />
+                  <h3>{course.name}</h3>
+                  <p>{course.description || '一门正在准备中的课程。'}</p>
+                  <div className="course-meta">
+                    <span>{course.category || '未分类'}</span>
+                    <span>
+                      {course.assets && course.assets.length > 0
+                        ? `${course.assets.length} 份课件资产`
+                        : '未上传课件'}
+                    </span>
+                    {course.status === 'COMPLETED' && (
+                      <span style={{ background: '#e8f5e9', color: '#2e7d32', fontWeight: 500 }}>
+                        <Sparkles size={12} style={{ display: 'inline', verticalAlign: '-1px' }} /> 成果可查收
+                      </span>
+                    )}
+                  </div>
+                </div>
+                <ArrowRight className="card-arrow" size={19} />
+              </Link>
+            ))}
+          </div>
+        )}
+      </section>
+    </div>
+  )
+}

+ 80 - 0
src/pages/NewCourse.tsx

@@ -0,0 +1,80 @@
+import { ArrowLeft, ArrowRight } from 'lucide-react'
+import { FormEvent, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { api } from '../api'
+
+export function NewCourse() {
+  const navigate = useNavigate()
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+
+  async function submit(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    setBusy(true)
+    const f = new FormData(e.currentTarget)
+    try {
+      const { course } = await api.createCourse({
+        name: String(f.get('name')),
+        category: String(f.get('category')),
+        audience: '',
+        description: String(f.get('description')),
+      })
+      navigate(`/courses/${course.id}`)
+    } catch (x) {
+      setError((x as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="page form-page">
+      <Link to="/" className="back-link">
+        <ArrowLeft size={16} />返回课程
+      </Link>
+      <div className="form-layout">
+        <aside>
+          <span className="step-number">01</span>
+          <p className="eyebrow">创建课程</p>
+          <h1>
+            先告诉我们,<br />
+            这是一门什么课。
+          </h1>
+          <p>填写基本信息后,可添加课程资产和讲师,也可以直接跳过。</p>
+        </aside>
+        <form className="paper-form" onSubmit={submit}>
+          <div className="form-title">
+            <span>COURSE PROFILE</span>
+            <h2>课程基本信息</h2>
+          </div>
+          <label>
+            课程名称 <b>*</b>
+            <input name="name" required maxLength={100} />
+          </label>
+          <label>
+            课程分类
+            <select name="category" defaultValue="党政专题">
+              <option>党政专题</option>
+              <option>高校公开课</option>
+              <option>企业培训</option>
+              <option>职业教育</option>
+              <option>其他</option>
+            </select>
+          </label>
+          <label>
+            课程简介
+            <textarea name="description" rows={4} maxLength={500} />
+          </label>
+          {error && <p className="form-error">{error}</p>}
+          <div className="form-footer">
+            <span>下一步:课程资产与讲师</span>
+            <button className="primary-button" disabled={busy}>
+              {busy ? '正在创建…' : '继续'}
+              <ArrowRight size={17} />
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 0
src/pages/Profile.tsx


+ 451 - 0
src/pages/admin/AdminCourseDetail.tsx

@@ -0,0 +1,451 @@
+import {
+  ArrowLeft,
+  CheckCircle2,
+  Download,
+  FileCheck,
+  FilePlus2,
+  FileText,
+  Loader2,
+  PackageCheck,
+  Play,
+  RotateCcw,
+  Save,
+  Send,
+  Sparkles,
+  Trash2,
+  Upload,
+  User,
+  Users,
+  Wrench,
+  X
+} from 'lucide-react'
+import { FormEvent, useEffect, useRef, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { api } from '../../api'
+import { StatusBadge } from '../../components/StatusBadge'
+import type { Course, CourseStatus } from '../../types'
+
+const size = (n: number) =>
+  n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
+
+export function AdminCourseDetail() {
+  const { id = '' } = useParams()
+  const [course, setCourse] = useState<Course | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+  const [successMsg, setSuccessMsg] = useState('')
+
+  // 交付物上传与说明
+  const deliverableInput = useRef<HTMLInputElement>(null)
+  const [delivFiles, setDelivFiles] = useState<File[]>([])
+  const [productionNotes, setProductionNotes] = useState('')
+
+  const loadCourse = () => {
+    setLoading(true)
+    api.admin
+      .getCourse(id)
+      .then((r) => {
+        setCourse(r.course)
+        setProductionNotes(r.course.productionNotes || '')
+      })
+      .catch((err) => setError(err.message))
+      .finally(() => setLoading(false))
+  }
+
+  useEffect(() => {
+    loadCourse()
+  }, [id])
+
+  // 快捷修改状态(如接单制作)
+  const updateStatus = async (status: CourseStatus) => {
+    setBusy(true)
+    setError('')
+    setSuccessMsg('')
+    try {
+      const res = await api.admin.updateStatus(id, status)
+      setCourse(res.course)
+      setSuccessMsg(`课程状态已更新为:${status === 'IN_PRODUCTION' ? '正在制作' : status}`)
+    } catch (err: any) {
+      setError(err.message || '更新状态失败')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  // 上传交付成果附件
+  const handleUploadDeliverables = async () => {
+    if (!delivFiles.length) return
+    setBusy(true)
+    setError('')
+    try {
+      const res = await api.admin.uploadDeliverables(id, delivFiles)
+      setCourse(res.course)
+      setDelivFiles([])
+      setSuccessMsg('交付成果文件上传成功')
+    } catch (err: any) {
+      setError(err.message || '上传交付文件失败')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  // 删除交付成果附件
+  const handleDeleteDeliverable = async (delivId: string) => {
+    if (!confirm('确定删除此交付文件吗?')) return
+    setBusy(true)
+    setError('')
+    try {
+      const res = await api.admin.deleteDeliverable(id, delivId)
+      setCourse(res.course)
+      setSuccessMsg('交付文件已删除')
+    } catch (err: any) {
+      setError(err.message || '删除失败')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  // 确认交付并完成
+  const handleCompleteCourse = async (e: FormEvent) => {
+    e.preventDefault()
+    setBusy(true)
+    setError('')
+    setSuccessMsg('')
+    try {
+      const res = await api.admin.completeCourse(id, productionNotes)
+      setCourse(res.course)
+      setSuccessMsg('🎉 课程已成功制作完成并交付给用户!')
+    } catch (err: any) {
+      setError(err.message || '交付提交失败')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  if (loading) return <div className="center-loading">正在读取课程制作信息…</div>
+  if (!course) return <div className="center-loading">未找到该课程信息</div>
+
+  return (
+    <div className="page admin-page">
+      <Link to="/admin/courses" className="back-link">
+        <ArrowLeft size={16} /> 返回课程制作列表
+      </Link>
+
+      {/* 顶部课程信息与当前状态控制栏 */}
+      <div className="admin-detail-header">
+        <div className="admin-detail-title">
+          <div className="title-row">
+            <h2>{course.name}</h2>
+            <StatusBadge status={course.status} />
+          </div>
+          <p className="admin-subtitle">
+            分类:<strong>{course.category || '未分类'}</strong>
+            {course.audience ? <> · 目标受众:<strong>{course.audience}</strong></> : null}
+          </p>
+        </div>
+
+        {/* 状态操作区 */}
+        <div className="status-action-box">
+          {course.status === 'WAITING_PRODUCTION' && (
+            <button
+              className="primary-button highlight-button"
+              disabled={busy}
+              onClick={() => updateStatus('IN_PRODUCTION')}
+            >
+              <Play size={16} /> 接单并开始制作
+            </button>
+          )}
+
+          {course.status === 'IN_PRODUCTION' && (
+            <div className="action-buttons-row">
+              <span className="status-pill status-pill-producing">
+                <Loader2 size={14} className="spin" /> 课程正在打磨中
+              </span>
+              <button
+                className="secondary-button"
+                disabled={busy}
+                onClick={() => updateStatus('WAITING_PRODUCTION')}
+                title="退回待制作状态"
+              >
+                <RotateCcw size={14} /> 退回排期
+              </button>
+            </div>
+          )}
+
+          {course.status === 'COMPLETED' && (
+            <div className="action-buttons-row">
+              <span className="status-pill status-pill-completed">
+                <CheckCircle2 size={14} /> 已交付给用户
+              </span>
+              <button
+                className="secondary-button"
+                disabled={busy}
+                onClick={() => updateStatus('IN_PRODUCTION')}
+                title="重新转为制作中状态"
+              >
+                <RotateCcw size={14} /> 重新打磨
+              </button>
+            </div>
+          )}
+
+          {course.status === 'DRAFT' && (
+            <button
+              className="secondary-button"
+              disabled={busy}
+              onClick={() => updateStatus('WAITING_PRODUCTION')}
+            >
+              强制转入待制作
+            </button>
+          )}
+        </div>
+      </div>
+
+      {error && <div className="notice notice-error">{error}</div>}
+      {successMsg && <div className="notice notice-success">{successMsg}</div>}
+
+      <div className="admin-detail-grid">
+        {/* 左侧栏:制作与交付操作中心 */}
+        <div className="admin-detail-left">
+          {/* 制作交付成果面板 */}
+          <section className="paper-form admin-panel">
+            <div className="form-title">
+              <span>DELIVERY & PRODUCTION</span>
+              <h3>课程制作与交付成果</h3>
+            </div>
+
+            <p className="panel-desc">
+              在此上传制作完成的成品文件(如成片视频、课件包等),并填写交付说明。提交后,用户可在其课程主页查收成果。
+            </p>
+
+            {/* 已上传的交付文件列表 */}
+            <div className="deliverable-list-section">
+              <h4>已附带的交付成果文件 ({course.deliverables?.length || 0})</h4>
+              {course.deliverables && course.deliverables.length > 0 ? (
+                <div className="deliverable-items">
+                  {course.deliverables.map((deliv) => (
+                    <div className="deliv-item" key={deliv.id}>
+                      <FileCheck size={18} className="icon-success" />
+                      <div className="deliv-meta">
+                        <strong>{deliv.originalName}</strong>
+                        <small>{size(deliv.size)}</small>
+                      </div>
+                      <a
+                        href={deliv.url}
+                        download={deliv.originalName}
+                        className="secondary-button small-button"
+                        target="_blank"
+                        rel="noreferrer"
+                      >
+                        <Download size={13} /> 预览/下载
+                      </a>
+                      <button
+                        className="icon-button delete-button"
+                        onClick={() => handleDeleteDeliverable(deliv.id)}
+                        title="删除此交付文件"
+                      >
+                        <Trash2 size={15} />
+                      </button>
+                    </div>
+                  ))}
+                </div>
+              ) : (
+                <p className="empty-sub-tip">暂未上传交付成品文件,请点击下方按钮上传。</p>
+              )}
+            </div>
+
+            {/* 上传新交付成品 */}
+            <div className="upload-deliv-trigger">
+              <input
+                hidden
+                multiple
+                ref={deliverableInput}
+                type="file"
+                onChange={(e) => setDelivFiles(Array.from(e.target.files || []))}
+              />
+              <button
+                type="button"
+                className="secondary-button"
+                onClick={() => deliverableInput.current?.click()}
+              >
+                <Upload size={15} /> 选择要交付的成品文件
+              </button>
+
+              {delivFiles.length > 0 && (
+                <div className="pending-upload-stack">
+                  <span>已选 {delivFiles.length} 个文件待上传:</span>
+                  {delivFiles.map((f, i) => (
+                    <span className="file-chip" key={i}>
+                      {f.name} ({size(f.size)})
+                    </span>
+                  ))}
+                  <button
+                    type="button"
+                    className="primary-button small-button"
+                    disabled={busy}
+                    onClick={handleUploadDeliverables}
+                  >
+                    {busy ? '上传中…' : '立即上传成果文件'}
+                  </button>
+                </div>
+              )}
+            </div>
+
+            <hr className="soft-divider" />
+
+            {/* 交付说明与寄语 */}
+            <form onSubmit={handleCompleteCourse}>
+              <label>
+                <strong>制作交付说明与外部链接</strong>
+                <textarea
+                  rows={5}
+                  value={productionNotes}
+                  onChange={(e) => setProductionNotes(e.target.value)}
+                  placeholder="填写交付说明、网盘链接、提取码、视频在线预览链接或给用户的制作寄语…"
+                />
+              </label>
+
+              <div className="admin-delivery-footer">
+                <button type="submit" className="primary-button full-button highlight-button" disabled={busy}>
+                  <PackageCheck size={18} />
+                  {course.status === 'COMPLETED' ? '更新交付信息' : '确认交付并完成制作'}
+                </button>
+              </div>
+            </form>
+          </section>
+
+          {/* 用户提交的原始素材库 */}
+          <section className="paper-form admin-panel">
+            <div className="form-title">
+              <span>CUSTOMER ASSETS</span>
+              <h3>用户上传的原始素材 ({course.assets?.length || 0})</h3>
+            </div>
+
+            {course.assets && course.assets.length > 0 ? (
+              <div className="asset-download-grid">
+                {course.assets.map((asset) => (
+                  <div className="asset-download-card" key={asset.id}>
+                    <FileText size={24} className="tone-primary-icon" />
+                    <div className="asset-download-info">
+                      <strong>{asset.originalName}</strong>
+                      <small>
+                        {size(asset.size)} · {new Date(asset.createdAt).toLocaleDateString('zh-CN')}
+                      </small>
+                    </div>
+                    <a
+                      href={asset.url}
+                      download={asset.originalName}
+                      className="primary-button small-button"
+                      target="_blank"
+                      rel="noreferrer"
+                    >
+                      <Download size={14} /> 下载
+                    </a>
+                  </div>
+                ))}
+              </div>
+            ) : (
+              <div className="empty-sub-tip">用户尚未上传任何课程资产文件。</div>
+            )}
+          </section>
+        </div>
+
+        {/* 右侧栏:用户诉求与讲师信息 */}
+        <div className="admin-detail-right">
+          {/* 用户画像与联系方式 */}
+          <section className="paper-form admin-sidebar-card">
+            <div className="form-title">
+              <span>CREATOR PROFILE</span>
+              <h4>提交用户信息</h4>
+            </div>
+
+            <div className="user-profile-summary">
+              <div className="user-avatar-large">
+                <User size={24} />
+              </div>
+              <div className="user-summary-text">
+                <strong>{course.creator?.contactName || '未填写联系人'}</strong>
+                <small>{course.creator?.organization || '个人创作者'}</small>
+              </div>
+            </div>
+
+            <div className="contact-list">
+              <div className="contact-item">
+                <span>联系电话:</span>
+                <strong>{course.creator?.phone || '—'}</strong>
+              </div>
+              <div className="contact-item">
+                <span>微信号:</span>
+                <strong>{course.creator?.wechat || '未填写'}</strong>
+              </div>
+              <div className="contact-item">
+                <span>单位名称:</span>
+                <strong>{course.creator?.organization || '未填写'}</strong>
+              </div>
+              {course.creator?.bio && (
+                <div className="contact-item flex-col">
+                  <span>用户补充说明:</span>
+                  <p>{course.creator.bio}</p>
+                </div>
+              )}
+            </div>
+          </section>
+
+          {/* 课程简介 */}
+          <section className="paper-form admin-sidebar-card">
+            <div className="form-title">
+              <span>COURSE INTRO</span>
+              <h4>课程详情与需求说明</h4>
+            </div>
+            <p className="course-full-desc">{course.description || '用户未填写详细课程简介。'}</p>
+            <div className="time-meta-box">
+              <div>
+                <small>创建时间</small>
+                <span>{new Date(course.createdAt).toLocaleString('zh-CN')}</span>
+              </div>
+              <div>
+                <small>提交制作时间</small>
+                <span>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '未提交'}</span>
+              </div>
+              {course.completedAt && (
+                <div>
+                  <small>制作交付时间</small>
+                  <span>{new Date(course.completedAt).toLocaleString('zh-CN')}</span>
+                </div>
+              )}
+            </div>
+          </section>
+
+          {/* 讲师团队信息 */}
+          <section className="paper-form admin-sidebar-card">
+            <div className="form-title">
+              <span>INSTRUCTORS</span>
+              <h4>讲师资料 ({course.instructors?.length || 0})</h4>
+            </div>
+
+            {course.instructors && course.instructors.length > 0 ? (
+              <div className="instructor-sidebar-list">
+                {course.instructors.map((inst) => (
+                  <div className="inst-card-detail" key={inst.id}>
+                    {inst.imageUrl ? (
+                      <img src={inst.imageUrl} alt={inst.name} className="inst-photo" />
+                    ) : (
+                      <div className="avatar-placeholder">{inst.name[0]}</div>
+                    )}
+                    <div className="inst-info">
+                      <strong>{inst.name}</strong>
+                      <small>{inst.organization || '未提供单位'}</small>
+                      <p>{inst.introduction || '暂无详细介绍'}</p>
+                    </div>
+                  </div>
+                ))}
+              </div>
+            ) : (
+              <p className="empty-sub-tip">未提供讲师图文信息。</p>
+            )}
+          </section>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 196 - 0
src/pages/admin/AdminCourses.tsx

@@ -0,0 +1,196 @@
+import {
+  ArrowRight,
+  Clock3,
+  FileText,
+  Filter,
+  PackageCheck,
+  RotateCw,
+  Search,
+  Users,
+  Wrench
+} from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../../api'
+import { StatusBadge } from '../../components/StatusBadge'
+import type { Course, CourseStatus } from '../../types'
+
+type StatusFilter = 'ALL' | CourseStatus
+
+export function AdminCourses() {
+  const [courses, setCourses] = useState<Course[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+  const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL')
+  const [keyword, setKeyword] = useState('')
+
+  const loadCourses = () => {
+    setLoading(true)
+    api.admin
+      .listCourses({ status: statusFilter, keyword })
+      .then((r) => setCourses(r.courses))
+      .catch((err) => setError(err.message))
+      .finally(() => setLoading(false))
+  }
+
+  useEffect(() => {
+    loadCourses()
+  }, [statusFilter])
+
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault()
+    loadCourses()
+  }
+
+  return (
+    <div className="page admin-page">
+      <div className="admin-header-row">
+        <div>
+          <span className="section-kicker">COURSE WORKSHOP</span>
+          <h2>课程制作管理</h2>
+          <p className="admin-subtitle">管理全平台用户创建的课程,跟踪排期与制作进度</p>
+        </div>
+        <button className="secondary-button" onClick={loadCourses} title="刷新列表">
+          <RotateCw size={15} /> 刷新
+        </button>
+      </div>
+
+      {error && <div className="notice notice-error">{error}</div>}
+
+      {/* 筛选与搜索工具条 */}
+      <div className="admin-filter-bar">
+        <div className="filter-tabs">
+          <button
+            className={`tab-item ${statusFilter === 'ALL' ? 'active' : ''}`}
+            onClick={() => setStatusFilter('ALL')}
+          >
+            全部课程
+          </button>
+          <button
+            className={`tab-item ${statusFilter === 'WAITING_PRODUCTION' ? 'active' : ''}`}
+            onClick={() => setStatusFilter('WAITING_PRODUCTION')}
+          >
+            等待制作
+          </button>
+          <button
+            className={`tab-item ${statusFilter === 'IN_PRODUCTION' ? 'active' : ''}`}
+            onClick={() => setStatusFilter('IN_PRODUCTION')}
+          >
+            正在制作
+          </button>
+          <button
+            className={`tab-item ${statusFilter === 'COMPLETED' ? 'active' : ''}`}
+            onClick={() => setStatusFilter('COMPLETED')}
+          >
+            已完成交付
+          </button>
+          <button
+            className={`tab-item ${statusFilter === 'DRAFT' ? 'active' : ''}`}
+            onClick={() => setStatusFilter('DRAFT')}
+          >
+            草稿中
+          </button>
+        </div>
+
+        <form className="search-form" onSubmit={handleSearch}>
+          <div className="input-with-icon">
+            <Search size={16} />
+            <input
+              value={keyword}
+              onChange={(e) => setKeyword(e.target.value)}
+              placeholder="搜索课程名、用户手机、姓名或单位…"
+            />
+          </div>
+          <button type="submit" className="secondary-button">
+            搜索
+          </button>
+        </form>
+      </div>
+
+      {/* 课程列表 */}
+      {loading ? (
+        <div className="center-loading">正在查询课程…</div>
+      ) : courses.length === 0 ? (
+        <div className="empty-state">
+          <Filter size={32} style={{ color: '#9aa3b3', marginBottom: 8 }} />
+          <h3>没有找到符合条件的课程</h3>
+          <p>尝试切换状态筛选条件或清空搜索关键字。</p>
+        </div>
+      ) : (
+        <div className="admin-table-wrapper">
+          <table className="admin-table">
+            <thead>
+              <tr>
+                <th>课程名称</th>
+                <th>分类 / 受众</th>
+                <th>提交用户</th>
+                <th>素材 / 讲师 / 交付物</th>
+                <th>当前状态</th>
+                <th>提交 / 交付时间</th>
+                <th style={{ textAlign: 'right' }}>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              {courses.map((course) => (
+                <tr key={course.id}>
+                  <td style={{ maxWidth: 280 }}>
+                    <div className="table-main-title">
+                      <strong>{course.name}</strong>
+                      {course.description && <small>{course.description}</small>}
+                    </div>
+                  </td>
+                  <td>
+                    <span>{course.category || '未分类'}</span>
+                    {course.audience ? <small className="sub-text">{course.audience}</small> : null}
+                  </td>
+                  <td>
+                    <div className="user-cell">
+                      <strong>{course.creator?.contactName || course.creator?.phone || '未知用户'}</strong>
+                      <small className="sub-text">{course.creator?.organization || '个人用户'}</small>
+                      <small className="sub-text">📞 {course.creator?.phone}</small>
+                    </div>
+                  </td>
+                  <td>
+                    <div className="badge-group">
+                      <span className="badge-count">
+                        <FileText size={12} /> {course.assets?.length || 0} 资产
+                      </span>
+                      <span className="badge-count">
+                        <Users size={12} /> {course.instructors?.length || 0} 讲师
+                      </span>
+                      {course.deliverables && course.deliverables.length > 0 && (
+                        <span className="badge-count deliverable-badge">
+                          <PackageCheck size={12} /> {course.deliverables.length} 交付
+                        </span>
+                      )}
+                    </div>
+                  </td>
+                  <td>
+                    <StatusBadge status={course.status} />
+                  </td>
+                  <td>
+                    <div className="time-cell">
+                      <small title="提交时间">
+                        提: {course.submittedAt ? new Date(course.submittedAt).toLocaleDateString('zh-CN') : '未提交'}
+                      </small>
+                      {course.completedAt && (
+                        <small title="交付时间" style={{ color: '#2e7d32' }}>
+                          成: {new Date(course.completedAt).toLocaleDateString('zh-CN')}
+                        </small>
+                      )}
+                    </div>
+                  </td>
+                  <td style={{ textAlign: 'right' }}>
+                    <Link to={`/admin/courses/${course.id}`} className="primary-button small-button">
+                      <Wrench size={13} /> 制作管理
+                    </Link>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+    </div>
+  )
+}

+ 198 - 0
src/pages/admin/AdminDashboard.tsx

@@ -0,0 +1,198 @@
+import {
+  ArrowRight,
+  BookOpen,
+  CheckCircle2,
+  Clock3,
+  FileText,
+  Loader2,
+  Shield,
+  Sparkles,
+  Users,
+  Wrench
+} from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../../api'
+import { StatusBadge } from '../../components/StatusBadge'
+import type { AdminStats, Course } from '../../types'
+
+export function AdminDashboard() {
+  const [stats, setStats] = useState<AdminStats | null>(null)
+  const [pendingCourses, setPendingCourses] = useState<Course[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    Promise.all([api.admin.getStats(), api.admin.listCourses()])
+      .then(([s, c]) => {
+        setStats(s)
+        // 过滤出等待制作和制作中的任务作为待办队列
+        const pending = c.courses.filter(
+          (item) => item.status === 'WAITING_PRODUCTION' || item.status === 'IN_PRODUCTION'
+        )
+        setPendingCourses(pending.slice(0, 8))
+      })
+      .catch((err) => setError(err.message))
+      .finally(() => setLoading(false))
+  }, [])
+
+  if (loading) return <div className="center-loading">正在加载管理数据…</div>
+
+  return (
+    <div className="page admin-page">
+      <div className="admin-header-row">
+        <div>
+          <span className="section-kicker">CONTROL CENTER</span>
+          <h2>管理概览看板</h2>
+          <p className="admin-subtitle">监控平台用户动态与课程制作全周期流转状态</p>
+        </div>
+        <div className="admin-header-actions">
+          <Link to="/admin/courses" className="primary-button">
+            <Wrench size={16} />
+            <span>进入制作工作台</span>
+          </Link>
+        </div>
+      </div>
+
+      {error && <div className="notice notice-error">{error}</div>}
+
+      {/* 统计指标卡片组 */}
+      <div className="stats-grid">
+        <div className="stat-card">
+          <div className="stat-icon tone-blue">
+            <Users size={22} />
+          </div>
+          <div className="stat-content">
+            <small>注册用户总数</small>
+            <strong>{stats?.totalUsers ?? 0}</strong>
+          </div>
+        </div>
+
+        <div className="stat-card">
+          <div className="stat-icon tone-slate">
+            <BookOpen size={22} />
+          </div>
+          <div className="stat-content">
+            <small>全平台课程数</small>
+            <strong>{stats?.totalCourses ?? 0}</strong>
+          </div>
+        </div>
+
+        <div className="stat-card highlight-waiting">
+          <div className="stat-icon tone-gold">
+            <Clock3 size={22} />
+          </div>
+          <div className="stat-content">
+            <small>等待制作 (待接单)</small>
+            <strong>{stats?.waitingCount ?? 0}</strong>
+          </div>
+        </div>
+
+        <div className="stat-card highlight-producing">
+          <div className="stat-icon tone-primary">
+            <Loader2 size={22} className={stats?.inProductionCount ? 'spin' : ''} />
+          </div>
+          <div className="stat-content">
+            <small>正在制作中</small>
+            <strong>{stats?.inProductionCount ?? 0}</strong>
+          </div>
+        </div>
+
+        <div className="stat-card highlight-completed">
+          <div className="stat-icon tone-green">
+            <CheckCircle2 size={22} />
+          </div>
+          <div className="stat-content">
+            <small>已制作交付</small>
+            <strong>{stats?.completedCount ?? 0}</strong>
+          </div>
+        </div>
+      </div>
+
+      {/* 待办制作工单队列 */}
+      <div className="admin-section-block">
+        <div className="section-heading">
+          <div>
+            <span className="section-kicker">PRODUCTION QUEUE</span>
+            <h3>待制作与进行中课程</h3>
+          </div>
+          <Link to="/admin/courses" className="text-link">
+            查看全部课程 <ArrowRight size={14} />
+          </Link>
+        </div>
+
+        {pendingCourses.length === 0 ? (
+          <div className="empty-state compact-empty">
+            <Sparkles size={32} style={{ color: '#245bd6', marginBottom: 8 }} />
+            <h3>当前没有待处理的制作任务</h3>
+            <p>当用户提交新课程后,系统会自动汇入此制作队列中。</p>
+          </div>
+        ) : (
+          <div className="admin-table-wrapper">
+            <table className="admin-table">
+              <thead>
+                <tr>
+                  <th>课程名称</th>
+                  <th>分类 / 受众</th>
+                  <th>提交用户</th>
+                  <th>素材与讲师</th>
+                  <th>状态</th>
+                  <th>提交时间</th>
+                  <th style={{ textAlign: 'right' }}>操作</th>
+                </tr>
+              </thead>
+              <tbody>
+                {pendingCourses.map((course) => (
+                  <tr key={course.id}>
+                    <td>
+                      <div className="table-main-title">
+                        <strong>{course.name}</strong>
+                        {course.description && <small>{course.description}</small>}
+                      </div>
+                    </td>
+                    <td>
+                      <span>{course.category || '未分类'}</span>
+                      {course.audience ? <small className="sub-text">{course.audience}</small> : null}
+                    </td>
+                    <td>
+                      <strong>{course.creator?.contactName || '未填姓名'}</strong>
+                      <small className="sub-text">{course.creator?.phone}</small>
+                    </td>
+                    <td>
+                      <span className="badge-count">
+                        <FileText size={12} /> {course.assets?.length || 0} 资产
+                      </span>
+                      <span className="badge-count">
+                        <Users size={12} /> {course.instructors?.length || 0} 讲师
+                      </span>
+                    </td>
+                    <td>
+                      <StatusBadge status={course.status} />
+                    </td>
+                    <td>
+                      <small>
+                        {course.submittedAt
+                          ? new Date(course.submittedAt).toLocaleDateString('zh-CN', {
+                              month: '2-digit',
+                              day: '2-digit',
+                              hour: '2-digit',
+                              minute: '2-digit'
+                            })
+                          : '—'}
+                      </small>
+                    </td>
+                    <td style={{ textAlign: 'right' }}>
+                      <Link to={`/admin/courses/${course.id}`} className="primary-button small-button">
+                        进入制作 <ArrowRight size={13} />
+                      </Link>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 79 - 0
src/pages/admin/AdminLayout.tsx

@@ -0,0 +1,79 @@
+import {
+  BookOpen,
+  ChevronRight,
+  ExternalLink,
+  Layers,
+  LayoutDashboard,
+  LogOut,
+  ShieldCheck,
+  Users
+} from 'lucide-react'
+import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'
+import { Brand } from '../../components/Brand'
+import { useAuth } from '../../AuthContext'
+
+export function AdminLayout() {
+  const { user, loading, logout } = useAuth()
+  const navigate = useNavigate()
+
+  if (loading) return <div className="center-loading">正在加载管理后台…</div>
+
+  // 未登录或非管理员,统一拦截跳转至管理员专属登录页 /admin/login
+  if (!user || user.role !== 'ADMIN') {
+    return <Navigate to="/admin/login" replace />
+  }
+
+  const handleAdminLogout = async () => {
+    await logout()
+    navigate('/admin/login', { replace: true })
+  }
+
+  return (
+    <div className="admin-shell">
+      <header className="admin-topbar">
+        <div className="admin-brand-wrap">
+          <NavLink to="/admin" className="logo-link">
+            <Brand />
+          </NavLink>
+          <div className="admin-badge">
+            <ShieldCheck size={14} />
+            <span>管理后台</span>
+          </div>
+        </div>
+
+        <nav className="admin-nav">
+          <NavLink to="/admin" end className={({ isActive }) => (isActive ? 'active' : '')}>
+            <LayoutDashboard size={16} />
+            <span>概览看板</span>
+          </NavLink>
+          <NavLink to="/admin/courses" className={({ isActive }) => (isActive ? 'active' : '')}>
+            <BookOpen size={16} />
+            <span>课程制作</span>
+          </NavLink>
+          <NavLink to="/admin/users" className={({ isActive }) => (isActive ? 'active' : '')}>
+            <Users size={16} />
+            <span>用户管理</span>
+          </NavLink>
+        </nav>
+
+        <div className="admin-top-actions">
+          <NavLink to="/" className="client-switch-button" target="_blank" rel="noreferrer" title="打开前台工坊查看用户视角">
+            <span>前台工坊</span>
+            <ExternalLink size={14} />
+          </NavLink>
+          <div className="admin-user-info">
+            <strong>{user.contactName || '系统管理员'}</strong>
+            <small>{user.phone}</small>
+          </div>
+          <button className="icon-button" title="退出管理员登录" onClick={handleAdminLogout}>
+            <LogOut size={18} />
+          </button>
+        </div>
+      </header>
+
+      <main className="admin-main">
+        <Outlet />
+      </main>
+    </div>
+  )
+}

+ 99 - 0
src/pages/admin/AdminLoginPage.tsx

@@ -0,0 +1,99 @@
+import { ArrowRight, LockKeyhole, Phone, ShieldCheck, Sparkles } from 'lucide-react'
+import { FormEvent, useState } from 'react'
+import { Link, Navigate, useNavigate } from 'react-router-dom'
+import { Brand } from '../../components/Brand'
+import { useAuth } from '../../AuthContext'
+
+export function AdminLoginPage() {
+  const { user, adminLogin } = useAuth()
+  const navigate = useNavigate()
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+
+  // 如果已经是管理员,直接跳转到管理后台
+  if (user && user.role === 'ADMIN') {
+    return <Navigate to="/admin" replace />
+  }
+
+  async function submit(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const f = new FormData(e.currentTarget)
+    setBusy(true)
+    setError('')
+    try {
+      await adminLogin(String(f.get('phone')), String(f.get('password')))
+      navigate('/admin', { replace: true })
+    } catch (err) {
+      setError(err instanceof Error ? err.message : '管理员登录失败')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="admin-login-page">
+      <div className="admin-login-card">
+        <div className="admin-login-head">
+          <div className="admin-login-brand">
+            <Brand />
+          </div>
+          <div className="admin-login-badge">
+            <ShieldCheck size={16} />
+            <span>管理控制台</span>
+          </div>
+          <h2>管理员登录</h2>
+          <p>仅限平台课程制作专家与系统管理员登录访问</p>
+        </div>
+
+        <form onSubmit={submit} className="admin-login-form">
+          <label>
+            <span>管理员账号 (手机号)</span>
+            <div className="input-with-icon">
+              <Phone />
+              <input
+                name="phone"
+                inputMode="numeric"
+                required
+                pattern="1[0-9]{10}"
+                placeholder="请输入管理员手机号"
+                defaultValue="18888888888"
+              />
+            </div>
+          </label>
+
+          <label>
+            <span>登录密码</span>
+            <div className="input-with-icon">
+              <LockKeyhole />
+              <input
+                name="password"
+                type="password"
+                required
+                minLength={6}
+                placeholder="请输入密码"
+                defaultValue="admin123456"
+              />
+            </div>
+          </label>
+
+          {error && <div className="notice notice-error">{error}</div>}
+
+          <button className="primary-button full-button highlight-button" disabled={busy}>
+            {busy ? '正在安全校验…' : '登录管理系统'}
+            <ArrowRight size={17} />
+          </button>
+        </form>
+
+        <div className="admin-login-footer">
+          <div className="admin-tip-box">
+            <Sparkles size={14} />
+            <span>默认管理员:18888888888 / admin123456</span>
+          </div>
+          <Link to="/login" className="back-to-client-link">
+            ← 返回普通创作者登录
+          </Link>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 206 - 0
src/pages/admin/AdminUsers.tsx

@@ -0,0 +1,206 @@
+import {
+  ArrowRight,
+  BookOpen,
+  CheckCircle2,
+  Clock3,
+  RotateCw,
+  Search,
+  Shield,
+  ShieldAlert,
+  ShieldCheck,
+  UserCheck,
+  Users
+} from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../../api'
+import { useAuth } from '../../AuthContext'
+import type { User } from '../../types'
+
+export function AdminUsers() {
+  const { user: currentUser } = useAuth()
+  const [users, setUsers] = useState<User[]>([])
+  const [loading, setLoading] = useState(true)
+  const [keyword, setKeyword] = useState('')
+  const [error, setError] = useState('')
+  const [successMsg, setSuccessMsg] = useState('')
+  const [busyId, setBusyId] = useState<string | null>(null)
+
+  const loadUsers = () => {
+    setLoading(true)
+    api.admin
+      .listUsers(keyword)
+      .then((r) => setUsers(r.users))
+      .catch((err) => setError(err.message))
+      .finally(() => setLoading(false))
+  }
+
+  useEffect(() => {
+    loadUsers()
+  }, [])
+
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault()
+    loadUsers()
+  }
+
+  const handleToggleRole = async (targetUser: User) => {
+    const nextRole = targetUser.role === 'ADMIN' ? 'USER' : 'ADMIN'
+    const confirmText =
+      nextRole === 'ADMIN'
+        ? `确定要将用户【${targetUser.contactName || targetUser.phone}】提升为管理员吗?`
+        : `确定要撤销用户【${targetUser.contactName || targetUser.phone}】的管理员权限吗?`
+
+    if (!confirm(confirmText)) return
+
+    setBusyId(targetUser.id)
+    setError('')
+    setSuccessMsg('')
+    try {
+      await api.admin.updateUserRole(targetUser.id, nextRole)
+      setSuccessMsg(`用户 ${targetUser.phone} 权限已更新为:${nextRole === 'ADMIN' ? '管理员' : '普通用户'}`)
+      loadUsers()
+    } catch (err: any) {
+      setError(err.message || '更新角色失败')
+    } finally {
+      setBusyId(null)
+    }
+  }
+
+  return (
+    <div className="page admin-page">
+      <div className="admin-header-row">
+        <div>
+          <span className="section-kicker">USER MANAGEMENT</span>
+          <h2>用户管理中心</h2>
+          <p className="admin-subtitle">查看全平台注册用户、课程创建活跃度及管理后台权限配置</p>
+        </div>
+        <button className="secondary-button" onClick={loadUsers} title="刷新列表">
+          <RotateCw size={15} /> 刷新
+        </button>
+      </div>
+
+      {error && <div className="notice notice-error">{error}</div>}
+      {successMsg && <div className="notice notice-success">{successMsg}</div>}
+
+      {/* 搜索栏 */}
+      <div className="admin-filter-bar">
+        <form className="search-form" onSubmit={handleSearch} style={{ maxWidth: 420 }}>
+          <div className="input-with-icon">
+            <Search size={16} />
+            <input
+              value={keyword}
+              onChange={(e) => setKeyword(e.target.value)}
+              placeholder="搜索手机号、姓名、单位或微信…"
+            />
+          </div>
+          <button type="submit" className="secondary-button">
+            搜索
+          </button>
+        </form>
+        <div className="user-stat-summary">
+          <span>共 {users.length} 位注册用户</span>
+        </div>
+      </div>
+
+      {/* 用户列表表格 */}
+      {loading ? (
+        <div className="center-loading">正在读取用户列表…</div>
+      ) : users.length === 0 ? (
+        <div className="empty-state">
+          <Users size={32} style={{ color: '#9aa3b3', marginBottom: 8 }} />
+          <h3>没有找到匹配的用户</h3>
+          <p>尝试更换搜索关键词。</p>
+        </div>
+      ) : (
+        <div className="admin-table-wrapper">
+          <table className="admin-table">
+            <thead>
+              <tr>
+                <th>用户姓名 / 手机号</th>
+                <th>所属单位 / 微信号</th>
+                <th>用户角色</th>
+                <th>课程统计</th>
+                <th>注册时间</th>
+                <th style={{ textAlign: 'right' }}>操作 / 权限</th>
+              </tr>
+            </thead>
+            <tbody>
+              {users.map((u) => (
+                <tr key={u.id}>
+                  <td>
+                    <div className="user-cell">
+                      <strong>{u.contactName || '未设置姓名'}</strong>
+                      <small className="sub-text">📞 {u.phone}</small>
+                      {u.bio && <small className="user-bio-tip">{u.bio}</small>}
+                    </div>
+                  </td>
+                  <td>
+                    <span>{u.organization || '未填写单位'}</span>
+                    <small className="sub-text">微信号:{u.wechat || '未填写'}</small>
+                  </td>
+                  <td>
+                    {u.role === 'ADMIN' ? (
+                      <span className="role-tag role-admin">
+                        <ShieldCheck size={13} /> 管理员
+                      </span>
+                    ) : (
+                      <span className="role-tag role-user">
+                        <UserCheck size={13} /> 普通创作者
+                      </span>
+                    )}
+                  </td>
+                  <td>
+                    <div className="badge-group">
+                      <span className="badge-count" title="总课程数">
+                        <BookOpen size={12} /> {u.coursesCount || 0} 门
+                      </span>
+                      {(u.waitingCoursesCount || 0) > 0 && (
+                        <span className="badge-count tone-gold" title="等待制作">
+                          <Clock3 size={12} /> {u.waitingCoursesCount} 待制作
+                        </span>
+                      )}
+                      {(u.completedCoursesCount || 0) > 0 && (
+                        <span className="badge-count tone-green" title="已完成交付">
+                          <CheckCircle2 size={12} /> {u.completedCoursesCount} 已完成
+                        </span>
+                      )}
+                    </div>
+                  </td>
+                  <td>
+                    <small>{new Date(u.createdAt).toLocaleDateString('zh-CN')}</small>
+                  </td>
+                  <td style={{ textAlign: 'right' }}>
+                    <div className="action-button-group">
+                      <Link
+                        to={`/admin/courses?keyword=${encodeURIComponent(u.phone)}`}
+                        className="secondary-button small-button"
+                        title="查看该用户的全部课程"
+                      >
+                        课程 ({u.coursesCount || 0})
+                      </Link>
+
+                      {/* 权限切换按钮 */}
+                      {u.id !== currentUser?.id && (
+                        <button
+                          type="button"
+                          disabled={busyId === u.id}
+                          className={`small-button ${
+                            u.role === 'ADMIN' ? 'secondary-button' : 'primary-button'
+                          }`}
+                          onClick={() => handleToggleRole(u)}
+                        >
+                          {u.role === 'ADMIN' ? '降为普通' : '设为管理员'}
+                        </button>
+                      )}
+                    </div>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+    </div>
+  )
+}

+ 358 - 0
src/styles.css

@@ -0,0 +1,358 @@
+:root { font-family: Inter,'SF Pro Display','PingFang SC','Microsoft YaHei',sans-serif; color:#18233a; background:#f5f7fb; font-synthesis:none; --blue:#245bd6; --blue-dark:#1646b5; --ink:#17213a; --muted:#78839a; --line:#e4e8f0; --paper:#fff; }
+* { box-sizing:border-box; }
+body { margin:0; min-width:320px; min-height:100vh; background:radial-gradient(circle at 85% 6%,rgba(56,107,228,.06),transparent 24%),#f5f7fb; }
+button,input,textarea,select { font:inherit; }
+button,a { -webkit-tap-highlight-color:transparent; }
+a { color:inherit; text-decoration:none; }
+button { cursor:pointer; }
+.app-shell { min-height:100vh; }
+.topbar { height:72px; padding:0 clamp(24px,5vw,76px); display:flex; align-items:center; border-bottom:1px solid rgba(220,225,235,.8); background:rgba(248,249,252,.82); backdrop-filter:blur(14px); position:sticky; top:0; z-index:20; }
+.logo-link { display:flex; }
+.brand { display:flex; align-items:center; gap:13px; min-width:172px; }
+.brand>span:last-child { display:flex; align-items:baseline; gap:8px; }
+.brand strong { font-size:22px; letter-spacing:.18em; font-weight:500; color:#101522; }
+.brand small { color:#9da4b3; font-size:9px; letter-spacing:.22em; }
+.brand-mark { width:42px; height:26px; position:relative; display:inline-block; overflow:hidden; }
+.brand-mark:before,.brand-mark:after { content:''; position:absolute; border:1px solid #627087; border-color:#627087 transparent transparent transparent; border-radius:50%; width:46px; height:24px; left:1px; top:9px; transform:rotate(-6deg); }
+.brand-mark:after { border-color:#aeb6c5 transparent transparent transparent; width:38px; height:17px; left:7px; top:15px; }
+.brand-mark i,.brand-mark b { width:5px; height:5px; border-radius:50%; position:absolute; z-index:2; }
+.brand-mark i { background:#c4a05b; left:0; top:14px; }.brand-mark b { background:#344159; right:1px; top:12px; }
+.topbar nav { display:flex; height:100%; gap:34px; align-items:center; margin-left:60px; color:#9aa3b3; font-size:14px; }
+.topbar nav a { color:#5b6880; font-weight:500; height:100%; display:flex; align-items:center; position:relative; }
+.topbar nav a.active { color:var(--blue); font-weight:600; }
+.topbar nav a.active:after { content:''; position:absolute; bottom:0; left:3px; right:3px; height:2px; background:var(--blue); }
+.admin-nav-link { color:#805ad5!important; display:inline-flex; align-items:center; gap:6px; font-weight:600!important; }
+.admin-tag { background:#805ad5; color:#fff; font-size:10px; padding:2px 6px; border-radius:4px; margin-left:4px; font-weight:500; }
+.nav-muted { cursor:default; }
+.top-actions { margin-left:auto; display:flex; align-items:center; gap:13px; }
+.icon-button { border:0; background:transparent; color:#8d96a7; padding:7px; display:grid; place-items:center; border-radius:6px; }
+.icon-button:hover { color:#18233a; background:rgba(0,0,0,.04); }
+.beta { display:flex; align-items:center; gap:6px; font-size:11px; color:#5572ad; border:1px solid #dbe4f7; background:#f0f4fd; border-radius:999px; padding:6px 10px; letter-spacing:.06em; }
+.page { width:min(1400px,100%); margin:0 auto; padding:0 clamp(24px,5vw,76px) 70px; }
+.hero { min-height:414px; display:flex; align-items:center; position:relative; overflow:hidden; border-bottom:1px solid var(--line); }
+.hero-copy { position:relative; z-index:2; padding-top:12px; }
+.eyebrow { font-size:11px!important; font-weight:600; letter-spacing:.18em; color:#5271ad!important; text-transform:uppercase; display:flex; align-items:center; gap:8px; margin:0 0 20px!important; }
+.hero h1 { color:#19233a; font-size:clamp(39px,4.3vw,64px); line-height:1.18; letter-spacing:-.045em; margin:0; font-weight:300; }
+.hero h1 em { font-style:normal; color:var(--blue); }
+.hero-copy>p:not(.eyebrow) { color:#7b8699; font-size:15px; margin:24px 0 30px; letter-spacing:.02em; }
+.primary-button,.secondary-button { border:0; border-radius:8px; min-height:44px; padding:0 19px; display:inline-flex; align-items:center; justify-content:center; gap:9px; font-weight:500; font-size:14px; transition:.2s ease; }
+.primary-button { background:var(--blue); color:#fff; box-shadow:0 8px 25px rgba(36,91,214,.18); }
+.primary-button:hover:not(:disabled) { background:var(--blue-dark); transform:translateY(-1px); }
+.primary-button:disabled { opacity:.48; cursor:not-allowed; box-shadow:none; }
+.secondary-button { border:1px solid #dce2ed; color:#536077; background:#fff; }
+.secondary-button:hover { border-color:#bfc9da; }
+.small-button { min-height:34px; padding:0 12px; font-size:12px; }
+.highlight-button { background:linear-gradient(135deg,#245bd6,#5e3cd4); color:#fff; box-shadow:0 8px 25px rgba(94,60,212,.25); }
+.highlight-button:hover:not(:disabled) { background:linear-gradient(135deg,#1b4dbd,#4a2cae); }
+.orbit { position:absolute; border:1px solid rgba(72,91,125,.18); border-color:rgba(72,91,125,.22) transparent transparent transparent; border-radius:50%; transform:rotate(-12deg); }
+.orbit-one { width:570px; height:280px; right:1%; top:99px; }
+.orbit-two { width:440px; height:210px; right:6%; top:135px; border-color:rgba(36,91,214,.14) transparent transparent transparent; }
+.star-dot { position:absolute; border-radius:50%; width:8px; height:8px; background:#c4a05b; right:8.4%; top:192px; box-shadow:-380px 50px 0 -2px #2d5fc5; }
+.hero-note { position:absolute; right:4%; top:165px; width:340px; z-index:2; }
+.hero-note span { display:block; letter-spacing:.36em; color:#aab1bf; font-size:10px; margin-bottom:18px; }
+.hero-note p { margin:0; color:#556176; font-weight:300; font-size:17px; line-height:1.8; max-width:260px; }
+.content-section { padding-top:43px; }
+.section-heading { display:flex; align-items:end; justify-content:space-between; margin-bottom:23px; }
+.section-kicker { color:#a0aabc; letter-spacing:.22em; font-size:9px; font-weight:600; }
+.section-heading h2 { margin:6px 0 0; font-size:24px; font-weight:500; }
+.metrics { display:flex; gap:20px; color:#8b96aa; font-size:12px; }
+.metrics span { display:flex; align-items:center; gap:7px; }
+.course-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
+.course-card { min-height:158px; border:1px solid #e5e9f1; background:rgba(255,255,255,.82); border-radius:12px; display:flex; padding:22px; position:relative; transition:.2s; overflow:hidden; }
+.course-card:hover { border-color:#cbd7ee; box-shadow:0 12px 40px rgba(24,48,91,.07); transform:translateY(-2px); }
+.course-number { width:70px; height:100%; min-height:110px; display:grid; place-items:center; border-radius:8px; color:#2d62ce; font-size:18px; font-weight:500; background:linear-gradient(145deg,#edf3ff,#f7f9fd); margin-right:20px; position:relative; overflow:hidden; }
+.course-number:after { content:''; position:absolute; width:80px; height:35px; border:1px solid rgba(44,95,200,.2); border-radius:50%; top:37px; left:11px; }
+.tone-1 { color:#6885a9; background:#f1f4f7; }
+.tone-2 { color:#b09052; background:#f8f5ed; }
+.course-card-main { min-width:0; padding-top:1px; }
+.course-card h3 { margin:11px 0 6px; font-size:17px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.course-card-main>p { font-size:12px; color:#8c96a8; margin:0 0 17px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.course-meta { display:flex; gap:8px; }
+.course-meta span { font-size:10px; color:#7d899c; background:#f4f6f9; padding:5px 8px; border-radius:4px; }
+.card-arrow { position:absolute; right:20px; top:24px; color:#b6bfce; }
+
+/* 状态徽章体系 */
+.status { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-weight:500; letter-spacing:.02em; padding:3px 9px; border-radius:20px; }
+.status i { width:6px; height:6px; border-radius:50%; }
+.status--draft { color:#627289; background:#f1f4f8; }
+.status--draft i { background:#8e9cb0; }
+.status--waiting_production { color:#b45309; background:#fef3c7; }
+.status--waiting_production i { background:#f59e0b; box-shadow:0 0 0 3px rgba(245,158,11,.2); }
+.status--in_production { color:#1d4ed8; background:#dbeafe; }
+.status--in_production i { background:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.2); }
+.status--completed { color:#15803d; background:#dcfce7; }
+.status--completed i { background:#22c55e; box-shadow:0 0 0 3px rgba(34,197,94,.2); }
+.status--rejected { color:#b91c1c; background:#fee2e2; }
+.status--rejected i { background:#ef4444; }
+
+.empty-state { background:rgba(255,255,255,.58); border:1px dashed #d9dfe9; min-height:260px; border-radius:12px; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:30px; }
+.empty-state h3 { margin:13px 0 5px; font-size:17px; font-weight:500; }
+.empty-state p { color:#939cad; font-size:13px; margin:0 0 15px; }
+.empty-state a { color:var(--blue); font-size:13px; display:flex; align-items:center; gap:5px; }
+.compact-empty { min-height:160px; padding:20px; }
+.empty-orbit { width:58px; height:33px; border:1px solid #aab4c4; border-color:#aab4c4 transparent transparent transparent; border-radius:50%; position:relative; transform:rotate(-9deg); }
+.empty-orbit i { position:absolute; width:6px; height:6px; background:#c4a05b; border-radius:50%; top:8px; left:-1px; }
+.skeleton-card { height:158px; border-radius:12px; background:linear-gradient(90deg,#eceff4,#f7f8fa,#eceff4); background-size:200% 100%; animation:shimmer 1.3s infinite; }
+.form-page,.workspace-page { padding-top:32px; }
+.back-link { color:#8c96a7; display:inline-flex; align-items:center; gap:7px; font-size:12px; margin-bottom:35px; }
+.back-link:hover { color:var(--blue); }
+.form-layout { display:grid; grid-template-columns:minmax(260px,360px) minmax(500px,700px); gap:clamp(40px,8vw,115px); align-items:start; justify-content:center; }
+.form-layout aside { padding-top:24px; }
+.step-number { color:#c2cada; font-size:11px; letter-spacing:.18em; border-bottom:1px solid #bdc7d9; padding-bottom:6px; }
+.form-layout aside .eyebrow { margin-top:44px!important; }
+.form-layout aside h1 { font-size:36px; font-weight:300; line-height:1.45; margin:0; letter-spacing:-.035em; }
+.form-layout aside>p:last-child { color:#8b96a9; font-size:13px; line-height:1.8; margin-top:25px; max-width:280px; }
+.paper-form { background:#fff; padding:38px 42px; border:1px solid #e5e9f0; border-radius:13px; box-shadow:0 20px 60px rgba(40,57,90,.06); }
+.form-title { padding-bottom:25px; border-bottom:1px solid #edf0f4; margin-bottom:27px; }
+.form-title span { font-size:9px; color:#a3adbd; letter-spacing:.2em; }
+.form-title h2 { font-size:21px; margin:7px 0 0; font-weight:500; }
+.form-title h3 { font-size:19px; margin:6px 0 0; font-weight:500; }
+.form-title h4 { font-size:16px; margin:4px 0 0; font-weight:500; }
+.paper-form label { display:flex; flex-direction:column; gap:9px; font-size:12px; color:#59657b; margin:0 0 22px; }
+.paper-form label b { color:#d86f67; }
+.paper-form input,.paper-form select,.paper-form textarea { border:1px solid #dfe4ec; border-radius:7px; color:#253149; background:#fbfcfe; padding:12px 13px; outline:none; width:100%; transition:.2s; }
+.paper-form textarea { resize:vertical; line-height:1.6; }
+.paper-form input:focus,.paper-form select:focus,.paper-form textarea:focus { border-color:#7599e8; box-shadow:0 0 0 3px rgba(36,91,214,.08); background:#fff; }
+.two-columns { display:grid; grid-template-columns:1fr 1fr; gap:15px; }
+.form-footer { border-top:1px solid #edf0f4; padding-top:22px; display:flex; justify-content:space-between; align-items:center; }
+.form-footer>span { font-size:11px; color:#a1aaba; }
+.form-error { color:#bc4e47; background:#fff4f3; padding:9px 12px; border-radius:6px; font-size:12px; }
+.workspace-head { display:flex; justify-content:space-between; align-items:end; border-bottom:1px solid var(--line); padding-bottom:28px; }
+.workspace-head .back-link { margin-bottom:22px; }
+.title-row { display:flex; align-items:center; gap:15px; flex-wrap:wrap; }
+.title-row h1 { margin:0; font-size:27px; font-weight:500; }
+.title-row h2 { margin:0; font-size:23px; font-weight:500; }
+.workspace-head>div>p { color:#929cad; font-size:12px; margin:8px 0 0; }
+.success-panel { margin:55px auto 0; max-width:720px; text-align:center; background:#fff; border:1px solid #e4e9f1; border-radius:14px; padding:50px 55px; box-shadow:0 22px 70px rgba(35,53,88,.06); }
+.success-panel .eyebrow { justify-content:center; margin-top:18px!important; margin-bottom:11px!important; }
+.success-panel h2 { font-size:28px; font-weight:400; margin:0 0 13px; }
+.success-panel>p:not(.eyebrow) { color:#8a95a7; font-size:13px; line-height:1.8; margin:0 auto; max-width:510px; }
+.completed-panel { border-color:#c8e6c9; background:linear-gradient(180deg,#fff 60%,#f5fbf7); }
+.producing-panel { border-color:#bfdbfe; background:linear-gradient(180deg,#fff 60%,#f0f6ff); }
+.producing-orbit-icon { color:#245bd6; margin-bottom:12px; }
+.success-badge-icon { color:#2e7d32; margin-bottom:12px; }
+.production-notes-box { background:#f4f9f4; border:1px solid #d4ecd7; border-radius:10px; padding:18px 22px; margin:24px 0; text-align:left; }
+.production-notes-box strong { font-size:13px; color:#276749; display:block; margin-bottom:8px; }
+.production-notes-box p { margin:0; font-size:13px; color:#2d3748; line-height:1.7; white-space:pre-line; }
+.deliverables-box { margin:24px 0; border:1px solid #e2e8f0; border-radius:10px; padding:20px; background:#fff; text-align:left; }
+.deliverables-box .box-header { display:flex; align-items:center; gap:8px; margin-bottom:15px; color:#2b6cb0; }
+.deliverables-box .box-header h4 { margin:0; font-size:15px; font-weight:600; }
+.deliverable-list { display:flex; flex-direction:column; gap:10px; }
+.deliverable-row { display:flex; align-items:center; gap:12px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:12px 16px; }
+.deliv-info { flex:1; min-width:0; }
+.deliv-info strong { display:block; font-size:13px; color:#2d3748; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.deliv-info span { font-size:11px; color:#718096; }
+.receipt { display:grid; grid-template-columns:1fr 1fr 1fr; background:#f7f9fc; border:1px solid #edf0f5; border-radius:9px; margin:30px 0 25px; text-align:left; }
+.receipt span { display:flex; flex-direction:column; gap:7px; padding:17px; min-width:0; border-right:1px solid #e6eaf1; }
+.receipt span:last-child { border:0; }
+.receipt small { color:#9ca6b5; font-size:9px; }
+.receipt strong { color:#536076; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.center-loading { min-height:60vh; display:grid; place-items:center; color:#8793a8; }
+.spin { animation:spin .8s linear infinite; }
+@keyframes spin { to { transform:rotate(360deg); } }
+@keyframes shimmer { to { background-position:-200% 0; } }
+
+.user-link { display:flex; align-items:center; gap:7px; color:#66738a; font-size:12px; padding:7px 10px; border-radius:7px; }
+.auth-page { min-height:100vh; display:grid; grid-template-columns:1.1fr .9fr; }
+.auth-brand { padding:64px clamp(40px,8vw,120px); display:flex; flex-direction:column; justify-content:space-between; background:radial-gradient(circle at 20% 70%,rgba(36,91,214,.12),transparent 38%),#eef3fc; }
+.auth-brand h1 { font-size:clamp(40px,5vw,67px); font-weight:300; line-height:1.2; }
+.auth-brand h1 em { font-style:normal; color:var(--blue); }
+.auth-card { align-self:center; width:min(430px,calc(100% - 48px)); margin:auto; background:#fff; border:1px solid var(--line); border-radius:14px; padding:42px; }
+.auth-card label,.instructor-form label { display:flex; flex-direction:column; gap:8px; font-size:12px; color:#59657b; margin:18px 0; }
+.input-with-icon { position:relative; }
+.input-with-icon svg { position:absolute; left:13px; top:13px; width:17px; color:#a1abbc; }
+.auth-card input,.instructor-form input,.instructor-form textarea { width:100%; border:1px solid #dfe4ec; border-radius:7px; padding:12px 13px; background:#fbfcfe; }
+.input-with-icon input { padding-left:41px; }
+.auth-switch { display:block; margin:18px auto 0; border:0; background:transparent; color:var(--blue); }
+.profile-grid { display:grid; grid-template-columns:minmax(500px,700px) 350px; gap:22px; justify-content:center; }
+.password-card { align-self:start; }
+.notice { padding:12px 16px; border-radius:8px; margin:14px 0; font-size:13px; }
+.notice-error { color:#ad504a; background:#fff1f0; border:1px solid #f3d9d7; }
+.notice-success { color:#2e7d32; background:#e8f5e9; border:1px solid #c8e6c9; }
+.asset-layout { display:grid; grid-template-columns:minmax(0,1fr) 390px; gap:22px; margin:31px auto 0; max-width:1160px; }
+.upload-panel { background:#fff; border:1px solid #e5e9f0; border-radius:12px; padding:32px 35px; }
+.instructor-panel { background:#fff; border:1px solid #e5e9f0; border-radius:12px; padding:28px; align-self:start; }
+.panel-heading { display:flex; gap:22px; margin-bottom:27px; }
+.panel-heading .step-number { height:25px; }
+.panel-heading .eyebrow { margin-bottom:9px!important; }
+.panel-heading h2 { margin:0; font-size:22px; font-weight:500; }
+.panel-heading>div>p:last-child { color:#8e98aa; font-size:12px; margin:8px 0 0; }
+.drop-zone { min-height:255px; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; border:1px dashed #cbd5e5; border-radius:10px; background:#fafcff; cursor:pointer; transition:.2s; }
+.drop-zone:hover,.drop-zone.dragging { border-color:#6e93e5; background:#f5f8ff; }
+.compact { min-height:140px; }
+.drop-zone h3 { font-size:15px; font-weight:500; margin:3px 0 6px; }
+.drop-zone small { font-size:10px; color:#adb5c3; }
+.asset-list { display:flex; flex-direction:column; gap:9px; margin-bottom:14px; }
+.asset-row { display:flex; align-items:center; gap:12px; border:1px solid #e3e8f0; border-radius:8px; padding:12px; background:#fafbfc; }
+.asset-row div { display:flex; flex-direction:column; min-width:0; }
+.asset-row span { font-size:10px; color:#9aa5b7; }
+.asset-row button,.instructor-card>button { margin-left:auto; border:0; background:transparent; color:#a4adbb; cursor:pointer; }
+.asset-row button:hover,.instructor-card>button:hover { color:#e53e3e; }
+.aside-title { display:flex; justify-content:space-between; align-items:center; }
+.aside-title button { height:34px; border:1px solid #dce3ed; background:#fff; border-radius:6px; display:flex; align-items:center; gap:6px; padding:0 12px; font-size:12px; }
+.instructor-form { padding:18px; background:#f8fafd; border-radius:9px; margin-top:14px; border:1px solid #e2e8f0; }
+.instructor-list { margin-top:14px; }
+.instructor-card { display:flex; gap:11px; padding:13px 0; border-bottom:1px solid #edf0f4; align-items:start; }
+.instructor-card img,.avatar,.avatar-placeholder { width:54px; height:65px; object-fit:cover; border-radius:7px; background:#e9effa; display:grid; place-items:center; flex:none; font-weight:600; color:#4a5568; }
+.instructor-card>div { display:flex; flex-direction:column; gap:4px; min-width:0; }
+.instructor-card small,.instructor-card p,.empty-tip,.submit-box p { font-size:11px; color:#8d98aa; }
+.instructor-card p { margin:3px 0; line-height:1.5; color:#4a5568; }
+.submit-box { border-top:1px solid #edf0f4; margin-top:20px; padding-top:18px; }
+.selected-stack { margin-top:12px; }
+.selected-file { display:flex; align-items:center; border:1px solid #dfe6f1; background:#f9fbff; border-radius:9px; padding:12px 14px; margin-top:8px; gap:10px; }
+.selected-file div { display:flex; flex-direction:column; min-width:0; }
+.selected-file strong { font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.selected-file span { font-size:10px; color:#97a2b4; }
+.selected-file button { margin-left:auto; border:0; background:transparent; color:#9ca6b6; cursor:pointer; }
+.full-button { width:100%; margin-top:14px; }
+
+/* ==================== ADMIN 管理端专属样式 ==================== */
+
+.admin-shell { min-height:100vh; background:#f4f6fa; }
+.admin-topbar { height:70px; padding:0 clamp(20px,4vw,60px); display:flex; align-items:center; border-bottom:1px solid #dde3ed; background:#ffffff; box-shadow:0 1px 4px rgba(0,0,0,.03); position:sticky; top:0; z-index:20; }
+.admin-brand-wrap { display:flex; align-items:center; gap:14px; }
+.admin-badge { display:inline-flex; align-items:center; gap:5px; background:linear-gradient(135deg,#742a2a,#9b2c2c); color:#fff; font-size:11px; font-weight:600; padding:4px 9px; border-radius:6px; letter-spacing:.05em; }
+.admin-nav { display:flex; height:100%; gap:26px; align-items:center; margin-left:45px; }
+.admin-nav a { color:#64748b; font-size:14px; font-weight:500; height:100%; display:flex; align-items:center; gap:7px; position:relative; }
+.admin-nav a:hover { color:#1e293b; }
+.admin-nav a.active { color:var(--blue); font-weight:600; }
+.admin-nav a.active:after { content:''; position:absolute; bottom:0; left:0; right:0; height:2px; background:var(--blue); }
+.admin-top-actions { margin-left:auto; display:flex; align-items:center; gap:16px; }
+.client-switch-button { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:#475569; background:#f1f5f9; border:1px solid #cbd5e1; padding:6px 12px; border-radius:6px; font-weight:500; }
+.client-switch-button:hover { background:#e2e8f0; color:#0f172a; }
+.admin-user-info { display:flex; flex-direction:column; text-align:right; font-size:12px; }
+.admin-user-info strong { color:#1e293b; }
+.admin-user-info small { color:#94a3b8; font-size:10px; }
+.admin-page { padding-top:28px; }
+.admin-header-row { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:25px; }
+.admin-header-row h2 { margin:5px 0 0; font-size:24px; font-weight:600; color:#0f172a; }
+.admin-subtitle { color:#64748b; font-size:13px; margin:4px 0 0; }
+
+/* 统计卡片网格 */
+.stats-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:16px; margin-bottom:28px; }
+.stat-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; display:flex; align-items:center; gap:16px; box-shadow:0 2px 6px rgba(0,0,0,.02); transition:.2s ease; }
+.stat-card:hover { transform:translateY(-2px); box-shadow:0 8px 20px rgba(0,0,0,.04); }
+.stat-icon { width:48px; height:48px; border-radius:10px; display:grid; place-items:center; flex-shrink:0; }
+.tone-blue { background:#ebf4ff; color:#2563eb; }
+.tone-slate { background:#f1f5f9; color:#475569; }
+.tone-gold { background:#fef3c7; color:#d97706; }
+.tone-primary { background:#ede9fe; color:#7c3aed; }
+.tone-green { background:#dcfce7; color:#16a34a; }
+.stat-content { display:flex; flex-direction:column; }
+.stat-content small { font-size:12px; color:#64748b; margin-bottom:4px; }
+.stat-content strong { font-size:24px; font-weight:600; color:#0f172a; line-height:1; }
+
+.admin-section-block { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:24px; margin-bottom:28px; }
+.text-link { color:var(--blue); font-size:13px; display:inline-flex; align-items:center; gap:5px; }
+
+/* 过滤与搜索条 */
+.admin-filter-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-bottom:20px; flex-wrap:wrap; }
+.filter-tabs { display:flex; background:#e2e8f0; padding:3px; border-radius:8px; gap:3px; }
+.tab-item { border:0; background:transparent; color:#64748b; font-size:13px; font-weight:500; padding:6px 14px; border-radius:6px; transition:.15s; }
+.tab-item.active { background:#fff; color:#0f172a; font-weight:600; box-shadow:0 1px 3px rgba(0,0,0,.08); }
+.search-form { display:flex; gap:8px; width:100%; max-width:380px; }
+.search-form .input-with-icon { flex:1; }
+.search-form input { border:1px solid #cbd5e1; border-radius:8px; padding:9px 12px 9px 36px; font-size:13px; background:#fff; outline:none; }
+.search-form input:focus { border-color:var(--blue); }
+.user-stat-summary { color:#64748b; font-size:13px; }
+
+/* 管理端表格 */
+.admin-table-wrapper { width:100%; overflow-x:auto; background:#fff; border:1px solid #e2e8f0; border-radius:12px; }
+.admin-table { width:100%; border-collapse:collapse; text-align:left; font-size:13px; }
+.admin-table th { background:#f8fafc; color:#475569; font-weight:600; font-size:12px; padding:14px 18px; border-bottom:1px solid #e2e8f0; white-space:nowrap; }
+.admin-table td { padding:16px 18px; border-bottom:1px solid #f1f5f9; vertical-align:middle; color:#1e293b; }
+.admin-table tr:last-child td { border-bottom:0; }
+.admin-table tr:hover td { background:#fafcff; }
+.table-main-title strong { display:block; font-size:14px; font-weight:500; color:#0f172a; margin-bottom:3px; }
+.table-main-title small { display:block; color:#64748b; font-size:11px; line-height:1.4; max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.sub-text { display:block; color:#64748b; font-size:11px; margin-top:2px; }
+.user-cell strong { display:block; font-size:13px; font-weight:500; }
+.user-bio-tip { color:#94a3b8; font-size:10px; display:block; margin-top:2px; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.time-cell small { display:block; font-size:11px; color:#64748b; }
+.badge-group { display:flex; flex-wrap:wrap; gap:6px; }
+.badge-count { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:#475569; background:#f1f5f9; padding:3px 7px; border-radius:4px; }
+.deliverable-badge { background:#e8f5e9; color:#16a34a; font-weight:500; }
+.role-tag { display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; }
+.role-admin { background:#fee2e2; color:#991b1b; }
+.role-user { background:#f1f5f9; color:#475569; }
+.action-button-group { display:flex; justify-content:flex-end; gap:8px; }
+
+/* 制作详情工作台 */
+.admin-detail-header { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:24px 28px; margin-bottom:24px; display:flex; justify-content:space-between; align-items:center; gap:20px; flex-wrap:wrap; }
+.admin-detail-title h2 { margin:0 0 6px; font-size:22px; font-weight:600; }
+.status-action-box { display:flex; align-items:center; gap:12px; }
+.action-buttons-row { display:flex; align-items:center; gap:10px; }
+.status-pill { display:inline-flex; align-items:center; gap:6px; font-size:13px; font-weight:500; padding:6px 14px; border-radius:20px; }
+.status-pill-producing { background:#dbeafe; color:#1e40af; }
+.status-pill-completed { background:#dcfce7; color:#166534; }
+
+.admin-detail-grid { display:grid; grid-template-columns:minmax(0,1.7fr) minmax(320px,1.1fr); gap:24px; align-items:start; }
+.admin-panel { padding:30px; margin-bottom:24px; }
+.panel-desc { font-size:13px; color:#64748b; line-height:1.6; margin:-10px 0 20px; }
+.deliverable-list-section h4 { margin:0 0 12px; font-size:14px; font-weight:600; color:#334155; }
+.deliverable-items { display:flex; flex-direction:column; gap:10px; margin-bottom:18px; }
+.deliv-item { display:flex; align-items:center; gap:12px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:12px 16px; }
+.deliv-meta { flex:1; min-width:0; }
+.deliv-meta strong { display:block; font-size:13px; color:#1e293b; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.deliv-meta small { color:#64748b; font-size:11px; }
+.icon-success { color:#16a34a; }
+.delete-button:hover { color:#ef4444; background:#fee2e2; }
+.upload-deliv-trigger { margin:16px 0; }
+.pending-upload-stack { display:flex; flex-direction:column; gap:8px; margin-top:12px; padding:12px; background:#eff6ff; border:1px dashed #93c5fd; border-radius:8px; font-size:12px; }
+.file-chip { color:#1e40af; font-weight:500; }
+.empty-sub-tip { font-size:12px; color:#94a3b8; padding:12px 0; }
+.admin-delivery-footer { margin-top:20px; }
+
+/* 用户素材网格 */
+.asset-download-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:14px; }
+.asset-download-card { background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:14px; display:flex; align-items:center; gap:12px; }
+.tone-primary-icon { color:#2563eb; flex-shrink:0; }
+.asset-download-info { flex:1; min-width:0; }
+.asset-download-info strong { display:block; font-size:13px; color:#1e293b; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.asset-download-info small { color:#64748b; font-size:11px; }
+
+/* 右侧侧边栏卡片 */
+.admin-sidebar-card { padding:24px; margin-bottom:20px; }
+.user-profile-summary { display:flex; align-items:center; gap:14px; margin-bottom:18px; padding-bottom:16px; border-bottom:1px solid #f1f5f9; }
+.user-avatar-large { width:48px; height:48px; border-radius:50%; background:#e0e7ff; color:#4338ca; display:grid; place-items:center; flex-shrink:0; }
+.user-summary-text strong { display:block; font-size:15px; color:#0f172a; }
+.user-summary-text small { color:#64748b; font-size:12px; }
+.contact-list { display:flex; flex-direction:column; gap:10px; font-size:13px; }
+.contact-item { display:flex; gap:8px; color:#475569; }
+.contact-item strong { color:#0f172a; }
+.contact-item.flex-col { flex-direction:column; gap:4px; margin-top:6px; }
+.contact-item p { margin:0; font-size:12px; color:#334155; line-height:1.5; background:#f8fafc; padding:10px; border-radius:6px; }
+.course-full-desc { font-size:13px; color:#334155; line-height:1.7; margin:0 0 16px; white-space:pre-line; }
+.time-meta-box { display:flex; flex-direction:column; gap:8px; padding:12px; background:#f8fafc; border-radius:8px; font-size:11px; color:#64748b; border:1px solid #f1f5f9; }
+.time-meta-box div { display:flex; justify-content:space-between; }
+.time-meta-box span { font-weight:500; color:#1e293b; }
+.instructor-sidebar-list { display:flex; flex-direction:column; gap:14px; }
+.inst-card-detail { display:flex; gap:12px; padding-bottom:14px; border-bottom:1px solid #f1f5f9; }
+.inst-card-detail:last-child { border-bottom:0; padding-bottom:0; }
+.inst-photo { width:56px; height:68px; object-fit:cover; border-radius:6px; flex-shrink:0; }
+.inst-info strong { display:block; font-size:13px; color:#0f172a; }
+.inst-info small { color:#64748b; font-size:11px; display:block; margin:2px 0 6px; }
+.inst-info p { margin:0; font-size:11px; color:#475569; line-height:1.5; }
+
+@media (max-width:960px) {
+  .admin-detail-grid { grid-template-columns:1fr; }
+  .admin-topbar nav { display:none; }
+}
+
+/* ==================== ADMIN 专属登录页样式 ==================== */
+.admin-login-page { min-height:100vh; display:grid; place-items:center; background:radial-gradient(circle at 50% 20%,rgba(36,91,214,.18),transparent 45%),radial-gradient(circle at 80% 80%,rgba(94,60,212,.12),transparent 40%),#0e1526; padding:24px; }
+.admin-login-card { width:min(440px,100%); background:rgba(19,28,49,.85); backdrop-filter:blur(20px); border:1px solid rgba(255,255,255,.1); border-radius:16px; padding:44px 38px; box-shadow:0 30px 80px rgba(0,0,0,.45); }
+.admin-login-head { text-align:center; margin-bottom:28px; }
+.admin-login-brand { display:flex; justify-content:center; margin-bottom:18px; filter:invert(1) hue-rotate(180deg); }
+.admin-login-badge { display:inline-flex; align-items:center; gap:6px; background:linear-gradient(135deg,#991b1b,#b91c1c); color:#fff; font-size:11px; font-weight:600; padding:4px 11px; border-radius:20px; letter-spacing:.08em; margin-bottom:14px; }
+.admin-login-head h2 { margin:0 0 8px; font-size:24px; font-weight:600; color:#f8fafc; }
+.admin-login-head p { margin:0; font-size:13px; color:#94a3b8; }
+.admin-login-form label { display:flex; flex-direction:column; gap:8px; font-size:12px; color:#cbd5e1; margin-bottom:20px; }
+.admin-login-form input { width:100%; border:1px solid rgba(255,255,255,.15); border-radius:8px; padding:12px 14px 12px 42px; background:rgba(15,23,42,.6); color:#f8fafc; font-size:14px; outline:none; transition:.2s; }
+.admin-login-form input:focus { border-color:#38bdf8; box-shadow:0 0 0 3px rgba(56,189,248,.15); background:rgba(15,23,42,.9); }
+.admin-login-form .input-with-icon svg { color:#64748b; top:14px; }
+.admin-login-footer { margin-top:24px; text-align:center; display:flex; flex-direction:column; gap:14px; }
+.admin-tip-box { background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.08); border-radius:8px; padding:8px 12px; font-size:11px; color:#94a3b8; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
+.back-to-client-link { font-size:12px; color:#60a5fa; transition:.15s; }
+.back-to-client-link:hover { color:#93c5fd; text-decoration:underline; }
+

+ 70 - 0
src/types.ts

@@ -0,0 +1,70 @@
+export type CourseStatus = 'DRAFT' | 'WAITING_PRODUCTION' | 'IN_PRODUCTION' | 'COMPLETED' | 'REJECTED'
+
+export interface User {
+  id: string
+  phone: string
+  role?: 'USER' | 'ADMIN'
+  organization: string
+  wechat: string
+  contactName: string
+  bio: string
+  createdAt: string
+  updatedAt?: string
+  coursesCount?: number
+  waitingCoursesCount?: number
+  inProductionCoursesCount?: number
+  completedCoursesCount?: number
+}
+
+export interface CourseAsset {
+  id: string
+  originalName: string
+  size: number
+  mimeType: string
+  url: string
+  createdAt: string
+}
+
+export interface Instructor {
+  id: string
+  name: string
+  organization: string
+  introduction: string
+  imageUrl: string | null
+}
+
+export interface CourseDeliverable {
+  id: string
+  originalName: string
+  size: number
+  mimeType: string
+  url: string
+  createdAt: string
+}
+
+export interface Course {
+  id: string
+  userId?: string
+  name: string
+  category: string
+  audience: string
+  description: string
+  status: CourseStatus
+  productionNotes?: string
+  assets: CourseAsset[]
+  instructors: Instructor[]
+  deliverables?: CourseDeliverable[]
+  creator?: User
+  createdAt: string
+  submittedAt: string | null
+  completedAt?: string | null
+}
+
+export interface AdminStats {
+  totalUsers: number
+  totalCourses: number
+  waitingCount: number
+  inProductionCount: number
+  completedCount: number
+  draftCount: number
+}

+ 9 - 0
tsconfig.app.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true,
+    "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler",
+    "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx"
+  },
+  "include": ["src"]
+}

+ 1 - 0
tsconfig.app.tsbuildinfo

@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/authcontext.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/components/brand.tsx","./src/components/layout.tsx","./src/components/statusbadge.tsx","./src/pages/authpage.tsx","./src/pages/courseworkspace.tsx","./src/pages/dashboard.tsx","./src/pages/newcourse.tsx","./src/pages/profile.tsx","./src/pages/admin/admincoursedetail.tsx","./src/pages/admin/admincourses.tsx","./src/pages/admin/admindashboard.tsx","./src/pages/admin/adminlayout.tsx","./src/pages/admin/adminloginpage.tsx","./src/pages/admin/adminusers.tsx"],"version":"5.7.3"}

+ 4 - 0
tsconfig.json

@@ -0,0 +1,4 @@
+{
+  "files": [],
+  "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+}

+ 7 - 0
tsconfig.node.json

@@ -0,0 +1,7 @@
+{
+  "compilerOptions": {
+    "target": "ES2022", "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext",
+    "strict": true, "skipLibCheck": true, "esModuleInterop": true, "noEmit": true, "types": ["node"]
+  },
+  "include": ["vite.config.ts", "server"]
+}

+ 1 - 0
tsconfig.node.tsbuildinfo

@@ -0,0 +1 @@
+{"root":["./vite.config.ts","./server/db.ts","./server/index.ts","./server/migrate.ts"],"version":"5.7.3"}

+ 1 - 0
uploads/.gitkeep

@@ -0,0 +1 @@
+

+ 7 - 0
vite.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+  plugins: [react()],
+  server: { port: 5173, proxy: { '/api': 'http://localhost:3001', '/uploads': 'http://localhost:3001' } },
+})

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio