Compare commits
12 Commits
e3d274cfd8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ae28e030d | ||
|
|
70656562b1 | ||
|
|
1a27177f51 | ||
|
|
66c82fd1ee | ||
|
|
24be9c550b | ||
|
|
1a0607fe8d | ||
|
|
f2817ab8fd | ||
|
|
e072f219e4 | ||
|
|
8ae0f57488 | ||
|
|
24eab34305 | ||
|
|
c8f0c37cd1 | ||
|
|
234da90ac6 |
613
README.md
@@ -1,2 +1,613 @@
|
|||||||
# miaojingAI
|
# 妙境 AI 创作平台
|
||||||
|
|
||||||
|
妙境是一个面向个人创作者、内容团队和私有化部署场景的 AI 多模态创作平台。平台围绕“文生图、图生图、文生视频、图生视频、图片反推提示词”构建完整创作链路,提供用户体系、积分/会员、订单支付、作品历史、公开画廊、模型供应商管理、系统配置、数据备份和在线升级能力。
|
||||||
|
|
||||||
|
项目基于 Next.js App Router、React、PostgreSQL、本地文件存储和 PM2 运行,支持本地 PostgreSQL 部署,也兼容 Supabase 作为数据库/认证底座。AI 生成能力既支持系统默认供应商,也支持用户自定义 OpenAI/New API 兼容接口。
|
||||||
|
|
||||||
|
## 项目截图
|
||||||
|
|
||||||
|
以下截图来自开发服务器的真实页面,用于快速了解平台界面和核心工作流。
|
||||||
|
|
||||||
|
### 首页
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 创作中心
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 作品画廊
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
### 创作能力
|
||||||
|
|
||||||
|
- 文生图:根据文本提示词生成图片,支持尺寸、比例、模型和提示词参数。
|
||||||
|
- 图生图:上传参考图后进行风格迁移、场景变换、细节重绘和创意延展。
|
||||||
|
- 文生视频:根据文字描述生成动态视频内容。
|
||||||
|
- 图生视频:基于静态图片生成动态视频。
|
||||||
|
- 图片反推提示词:从图片中提取提示词,支持普通提示词、复刻级像素提示词、像素级图生图、像素级文生图等模式。
|
||||||
|
- 生成任务队列:生成任务写入 `generation_jobs`,前端可轮询任务状态并从历史记录中查看结果。
|
||||||
|
- 作品管理:保存创作历史、生成参数、结果链接、尺寸、时长、消耗积分等信息。
|
||||||
|
- 画廊发布:用户可将作品公开到画廊,支持点赞、复制提示词、全屏预览和下载。
|
||||||
|
|
||||||
|
### 管理后台
|
||||||
|
|
||||||
|
管理后台入口为 `/console`,管理员登录后可进入仪表盘和各类管理模块。
|
||||||
|
|
||||||
|
- 仪表盘:统计用户、作品、任务、订单、供应商、公告、日志和系统健康状态。
|
||||||
|
- API 管理:配置系统供应商、模型推荐、系统 API、New API/OpenAI 兼容站点。
|
||||||
|
- 用户管理:管理用户资料、角色、会员、积分、账号状态。
|
||||||
|
- 价格设置:维护会员套餐、积分规则和付费能力。
|
||||||
|
- 订单管理:查看订单、支付状态和收入统计。
|
||||||
|
- 支付配置:配置微信、支付宝、Stripe 等支付方式的展示和密钥。
|
||||||
|
- 公告管理:创建站点公告、弹窗公告、有效期和展示策略。
|
||||||
|
- 数据管理:导出/导入业务数据,适合迁移和人工备份。
|
||||||
|
- 系统升级:支持热更新和冷更新,自动备份、失败回滚、中文日志和历史记录。
|
||||||
|
- 系统日志:查看登录、安全、运行、管理操作等平台日志。
|
||||||
|
- 系统设置:维护站点名称、Logo、页脚、邮箱、通知和站点政策内容。
|
||||||
|
|
||||||
|
### 运维能力
|
||||||
|
|
||||||
|
- 一键部署/升级脚本:`scripts/deploy-or-upgrade.sh`
|
||||||
|
- 构建脚本:`scripts/build.sh`
|
||||||
|
- 数据备份:`scripts/backup-create.sh`
|
||||||
|
- 数据恢复:`scripts/backup-restore.sh`
|
||||||
|
- 备份列表:`scripts/backup-list.sh`
|
||||||
|
- 数据库初始化:`scripts/init-database.sql`
|
||||||
|
- 数据库补丁:`scripts/apply-database-patch.sh`
|
||||||
|
- 管理后台在线升级 runner:`scripts/admin-upgrade-runner.mjs`
|
||||||
|
- PM2 运行配置:`ecosystem.config.cjs`
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
| --- | --- |
|
||||||
|
| 前端框架 | Next.js 16 App Router、React 19、TypeScript |
|
||||||
|
| UI 组件 | Radix UI、Tailwind CSS、lucide-react、sonner |
|
||||||
|
| 服务端 | Next.js Route Handlers、自定义 Node HTTP server、tsup |
|
||||||
|
| 数据库 | PostgreSQL 14+,可接 Supabase |
|
||||||
|
| 存储 | 本地文件存储,生产推荐 `LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage` |
|
||||||
|
| 认证 | 本地 auth schema + session/JWT,兼容 Supabase 风格表结构 |
|
||||||
|
| AI 调用 | coze-coding-dev-sdk、用户自定义 API、系统 API、New API/OpenAI compatible |
|
||||||
|
| 进程管理 | PM2 |
|
||||||
|
| 构建工具 | pnpm、Turbopack、tsup、TypeScript |
|
||||||
|
|
||||||
|
## 系统架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ 首页 / 创作中心 / 画廊 / 个人中心 / 管理后台 Console │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ HTTP
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ Next.js App Router │
|
||||||
|
│ app pages + route handlers + middleware + server components │
|
||||||
|
└───────────────┬──────────────────────┬──────────────────────┘
|
||||||
|
│ │
|
||||||
|
┌───────────────▼──────────────┐ │
|
||||||
|
│ API Route 层 │ │
|
||||||
|
│ /api/generate/image │ │
|
||||||
|
│ /api/generate/video │ │
|
||||||
|
│ /api/generation-jobs │ │
|
||||||
|
│ /api/admin/* │ │
|
||||||
|
│ /api/local-storage/* │ │
|
||||||
|
└───────────────┬──────────────┘ │
|
||||||
|
│ │
|
||||||
|
┌───────────────▼──────────────┐ │
|
||||||
|
│ 业务服务层 │ │
|
||||||
|
│ auth / model config / jobs │ │
|
||||||
|
│ credits / orders / storage │ │
|
||||||
|
│ platform logs / upgrade │ │
|
||||||
|
└───────┬──────────────┬───────┘ │
|
||||||
|
│ │ │
|
||||||
|
┌───────▼──────┐ ┌─────▼────────┐ ┌────▼──────────────────┐
|
||||||
|
│ PostgreSQL │ │ 本地文件存储 │ │ 上游 AI / New API 站点 │
|
||||||
|
│ profiles │ │ images/videos│ │ OpenAI compatible │
|
||||||
|
│ works │ │ avatars │ │ image/video providers │
|
||||||
|
│ jobs/orders │ │ backups │ │ │
|
||||||
|
└──────────────┘ └──────────────┘ └───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── assets/ # 项目内置图片资源
|
||||||
|
├── docs/images/ # README 使用的项目截图
|
||||||
|
├── public/ # favicon、logo、公开静态文件
|
||||||
|
├── scripts/
|
||||||
|
│ ├── admin-upgrade-runner.mjs # 管理后台热/冷更新执行器
|
||||||
|
│ ├── apply-database-patch.sh # 执行数据库补丁
|
||||||
|
│ ├── backup-create.sh # 创建数据库/存储/.env 备份
|
||||||
|
│ ├── backup-list.sh # 查看备份列表
|
||||||
|
│ ├── backup-restore.sh # 恢复备份
|
||||||
|
│ ├── build.sh # Next.js + server 构建
|
||||||
|
│ ├── deploy-or-upgrade.sh # 一键部署/升级
|
||||||
|
│ ├── dev.sh # 本地开发启动脚本
|
||||||
|
│ ├── init-database.sql # PostgreSQL 初始化脚本
|
||||||
|
│ └── start.sh # 生产启动脚本
|
||||||
|
├── src/
|
||||||
|
│ ├── app/ # Next.js App Router 页面与 API
|
||||||
|
│ ├── components/ # 页面组件、创作组件、管理后台组件、UI 组件
|
||||||
|
│ ├── hooks/ # 前端 hooks
|
||||||
|
│ ├── lib/ # 业务逻辑、认证、模型、存储、日志、支付等
|
||||||
|
│ ├── modules/ # api / console / web 模块入口
|
||||||
|
│ ├── server.ts # 自定义 Node HTTP server 入口
|
||||||
|
│ └── storage/ # 数据库客户端和存储适配
|
||||||
|
├── .env.example # 环境变量模板
|
||||||
|
├── ecosystem.config.cjs # PM2 配置
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
### 基础环境
|
||||||
|
|
||||||
|
- Linux 服务器,推荐 Ubuntu 22.04+ / Debian 12+
|
||||||
|
- Node.js 22 或 24,部署脚本默认安装/使用 Node.js 24 LTS
|
||||||
|
- pnpm 9+
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- PM2
|
||||||
|
- curl、tar、rsync
|
||||||
|
- PostgreSQL 客户端工具:`psql`、`pg_dump`、`pg_restore`
|
||||||
|
|
||||||
|
### 推荐生产目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/miaojingAI # 项目代码目录
|
||||||
|
/var/lib/miaojingAI/storage # 上传文件、生成结果、本地存储
|
||||||
|
/var/lib/miaojingAI/backups # 数据备份
|
||||||
|
/var/lib/miaojingAI/upgrade # 管理后台升级状态和升级包
|
||||||
|
/var/lib/miaojingAI/logs # 部署日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
复制模板:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
常用变量:
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `LOCAL_DB_URL` | PostgreSQL 连接地址,例如 `postgresql://postgres:postgres@localhost:5432/miaojing` |
|
||||||
|
| `LOCAL_DB_ANON_KEY` | 本地模式 anon key,可自定义 |
|
||||||
|
| `LOCAL_DB_SERVICE_ROLE_KEY` | 本地模式 service role key,可自定义 |
|
||||||
|
| `DATA_ENCRYPTION_KEY` | 生产环境必填,用于加密 API Key 等敏感数据 |
|
||||||
|
| `JWT_SECRET` | 生产环境必填,用于会话/JWT 签名 |
|
||||||
|
| `GENERATION_INTERNAL_SECRET` | 生成任务内部密钥 |
|
||||||
|
| `LOCAL_STORAGE_DIR` | 本地文件存储路径 |
|
||||||
|
| `BACKUP_DIR` | 备份目录 |
|
||||||
|
| `DEPLOY_RUN_PORT` | 当前运行角色监听端口 |
|
||||||
|
| `MIAOJING_API_PORT` | 后端 API 内部端口 |
|
||||||
|
| `MIAOJING_CONSOLE_PORT` | 管理后台内部端口 |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | 前端公开访问地址 |
|
||||||
|
| `APP_BASE_URL` | 服务端使用的站点地址 |
|
||||||
|
| `ADMIN_INVITE_CODE` | 管理员邀请码 |
|
||||||
|
| `ENABLE_DANGER_ADMIN_CLEAR_USERS` | 危险清理功能开关,生产环境应保持 `false` |
|
||||||
|
| `UPGRADE_STATE_DIR` | 管理后台升级状态目录,默认基于存储目录推导 |
|
||||||
|
| `UPGRADE_HEALTH_URL` | 冷更新重启后的健康检查 URL |
|
||||||
|
| `UPGRADE_RESTART_COMMAND` | 冷更新使用的重启命令 |
|
||||||
|
|
||||||
|
生成生产密钥示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack enable
|
||||||
|
corepack pnpm install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 初始化数据库
|
||||||
|
|
||||||
|
创建数据库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
createdb miaojing
|
||||||
|
```
|
||||||
|
|
||||||
|
执行初始化脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "postgresql://postgres:postgres@localhost:5432/miaojing" -f scripts/init-database.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
至少填写:
|
||||||
|
|
||||||
|
```env
|
||||||
|
LOCAL_DB_URL=postgresql://postgres:postgres@localhost:5432/miaojing
|
||||||
|
LOCAL_DB_ANON_KEY=local-anon-key
|
||||||
|
LOCAL_DB_SERVICE_ROLE_KEY=local-service-role-key
|
||||||
|
LOCAL_STORAGE_DIR=./local-storage
|
||||||
|
DATA_ENCRYPTION_KEY=替换为 openssl rand -hex 32 的结果
|
||||||
|
JWT_SECRET=替换为 openssl rand -hex 32 的结果
|
||||||
|
GENERATION_INTERNAL_SECRET=替换为 openssl rand -hex 32 的结果
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 启动开发服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认开发端口由 `scripts/dev.sh` 控制,可传入端口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run dev -- 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 常用开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript 检查
|
||||||
|
corepack pnpm run ts-check
|
||||||
|
|
||||||
|
# ESLint
|
||||||
|
corepack pnpm run lint
|
||||||
|
|
||||||
|
# 完整构建
|
||||||
|
corepack pnpm run build
|
||||||
|
|
||||||
|
# 边界检查
|
||||||
|
corepack pnpm run check:boundaries
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生产部署
|
||||||
|
|
||||||
|
### 方式一:一键部署/升级脚本
|
||||||
|
|
||||||
|
推荐使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/deploy-or-upgrade.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会提示填写:
|
||||||
|
|
||||||
|
- 项目部署目录
|
||||||
|
- 数据存储目录
|
||||||
|
- 前端访问端口
|
||||||
|
- 后端 API 内部端口
|
||||||
|
- 管理后台内部端口
|
||||||
|
- 管理员账号/邮箱/密码
|
||||||
|
- PostgreSQL 连接地址
|
||||||
|
- 正式访问地址
|
||||||
|
|
||||||
|
首次部署时,脚本会:
|
||||||
|
|
||||||
|
1. 检查 tar、rsync、curl、psql、pg_dump 等依赖。
|
||||||
|
2. 自动安装或切换 Node.js 22/24。
|
||||||
|
3. 安装 pnpm 和 PM2。
|
||||||
|
4. 准备 `.env.local`。
|
||||||
|
5. 初始化或检查数据库。
|
||||||
|
6. 构建 Next.js 和 Node server。
|
||||||
|
7. 写入 PM2 配置并启动服务。
|
||||||
|
8. 执行健康检查。
|
||||||
|
|
||||||
|
升级已有部署时,脚本会:
|
||||||
|
|
||||||
|
1. 检测已有部署目录。
|
||||||
|
2. 创建升级前备份。
|
||||||
|
3. 同步新代码。
|
||||||
|
4. 构建并重启服务。
|
||||||
|
5. 执行健康检查。
|
||||||
|
6. 失败时输出备份路径,方便手动恢复。
|
||||||
|
|
||||||
|
### 方式二:手动部署
|
||||||
|
|
||||||
|
安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack enable
|
||||||
|
corepack pnpm install --frozen-lockfile --prod=false
|
||||||
|
```
|
||||||
|
|
||||||
|
构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
启动 PM2:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:5100/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### PM2 服务说明
|
||||||
|
|
||||||
|
`ecosystem.config.cjs` 默认包含三个角色:
|
||||||
|
|
||||||
|
| PM2 名称 | 角色 | 默认端口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `miaojing-api` | 后端 API | `5100` |
|
||||||
|
| `miaojing-web` | 前端站点 | `5000` |
|
||||||
|
| `miaojing-console` | 管理后台 | `5200` |
|
||||||
|
|
||||||
|
在单进程开发部署中,也可以使用类似 `miaojing-dev` 的 PM2 进程直接运行完整服务。
|
||||||
|
|
||||||
|
## 数据库说明
|
||||||
|
|
||||||
|
数据库初始化脚本为:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/init-database.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
核心表包括:
|
||||||
|
|
||||||
|
| 表 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `auth.users` | 本地认证用户 |
|
||||||
|
| `profiles` | 用户资料、角色、会员、积分 |
|
||||||
|
| `works` | 创作作品、提示词、结果 URL、尺寸、状态 |
|
||||||
|
| `generation_jobs` | 生成任务队列和进度 |
|
||||||
|
| `credit_transactions` | 积分流水 |
|
||||||
|
| `orders` | 订单和支付状态 |
|
||||||
|
| `user_api_keys` | 用户自定义 API 密钥 |
|
||||||
|
| `system_api_configs` | 系统默认 API 配置 |
|
||||||
|
| `api_providers` | 模型供应商 |
|
||||||
|
| `model_recommendations` | 模型推荐配置 |
|
||||||
|
| `announcements` | 公告 |
|
||||||
|
| `site_config` | 站点配置 |
|
||||||
|
| `platform_logs` | 平台日志 |
|
||||||
|
|
||||||
|
执行数据库补丁:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run db:patch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 备份与恢复
|
||||||
|
|
||||||
|
### 创建备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run backup:create
|
||||||
|
```
|
||||||
|
|
||||||
|
备份包包含:
|
||||||
|
|
||||||
|
- PostgreSQL dump:`database.dump`
|
||||||
|
- 本地存储目录:`local-storage`
|
||||||
|
- 环境变量:`.env.local`
|
||||||
|
- `package.json`
|
||||||
|
- 备份 manifest
|
||||||
|
|
||||||
|
默认只保留最近 10 个备份。
|
||||||
|
|
||||||
|
### 查看备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run backup:list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 恢复备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run backup:restore /path/to/miaojing-backup-YYYYMMDD-HHMMSS.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复动作会:
|
||||||
|
|
||||||
|
1. 使用 `pg_restore --clean --if-exists --no-owner` 恢复数据库。
|
||||||
|
2. 替换本地存储目录。
|
||||||
|
3. 恢复 `.env.local`。
|
||||||
|
|
||||||
|
生产环境恢复前建议先停服务,并额外复制当前数据目录。
|
||||||
|
|
||||||
|
## 管理后台在线升级
|
||||||
|
|
||||||
|
管理后台“系统升级”提供两类升级能力:
|
||||||
|
|
||||||
|
### 热更新
|
||||||
|
|
||||||
|
用于不影响运行时代码的小补丁,例如 `public/` 下的静态资源。
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 不重启平台。
|
||||||
|
- 升级前自动创建数据库/存储/环境备份。
|
||||||
|
- 升级前自动创建源码快照。
|
||||||
|
- 失败自动回滚。
|
||||||
|
- 升级界面实时显示中文日志。
|
||||||
|
|
||||||
|
### 冷更新
|
||||||
|
|
||||||
|
用于涉及源码、脚本、依赖、配置的大变更。
|
||||||
|
|
||||||
|
冷更新流程:
|
||||||
|
|
||||||
|
1. 上传升级包。
|
||||||
|
2. 校验升级包路径,拒绝 `.env`、`.git`、`node_modules`、`backups`、`local-storage` 等危险路径。
|
||||||
|
3. 创建数据库、存储、环境配置备份。
|
||||||
|
4. 创建源码快照。
|
||||||
|
5. 应用升级包文件。
|
||||||
|
6. 如依赖变化,执行 `pnpm install --frozen-lockfile --prod=false`。
|
||||||
|
7. 执行 `pnpm run ts-check`。
|
||||||
|
8. 执行 `pnpm run build`。
|
||||||
|
9. 重启平台。
|
||||||
|
10. 调用 `/api/health` 做健康检查。
|
||||||
|
11. 失败时自动恢复源码和数据。
|
||||||
|
|
||||||
|
升级日志会写入磁盘状态文件。即使冷更新过程中平台重启,管理后台恢复后也能续上日志和状态。升级完成后,当前页面刷新或切换页面会默认收起实时日志,但历史升级记录中可随时查看完整升级内容和日志。
|
||||||
|
|
||||||
|
升级包格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.tar
|
||||||
|
.tar.gz
|
||||||
|
.tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐升级包结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
upgrade-package/
|
||||||
|
├── manifest.json
|
||||||
|
├── src/...
|
||||||
|
├── public/...
|
||||||
|
├── scripts/...
|
||||||
|
├── package.json
|
||||||
|
└── pnpm-lock.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
手动运行升级 runner 示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
UPGRADE_STATE_DIR=/var/lib/miaojingAI/upgrade \
|
||||||
|
corepack pnpm run upgrade:run -- \
|
||||||
|
--job-id manual-001 \
|
||||||
|
--mode cold \
|
||||||
|
--package /tmp/upgrade.tgz \
|
||||||
|
--package-name upgrade.tgz \
|
||||||
|
--project /opt/miaojingAI
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI 模型与供应商
|
||||||
|
|
||||||
|
妙境支持多来源模型配置:
|
||||||
|
|
||||||
|
- 系统默认供应商。
|
||||||
|
- 用户自定义 API Key。
|
||||||
|
- New API / OpenAI-compatible API 站点。
|
||||||
|
- 图片模型、视频模型、文本模型分类型管理。
|
||||||
|
|
||||||
|
管理员可在管理后台配置供应商、默认模型、API 地址、模型推荐和启用状态。用户可在前端绑定自己的 API Key,平台会使用加密存储和尾号预览保护密钥。
|
||||||
|
|
||||||
|
## 存储与下载
|
||||||
|
|
||||||
|
生成结果会持久化到本地存储目录或配置的存储服务。推荐生产环境把存储放在项目目录之外:
|
||||||
|
|
||||||
|
```env
|
||||||
|
LOCAL_STORAGE_DIR=/var/lib/miaojingAI/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
下载链路通过 `/api/download` 或本地存储路由读取原始文件,不应对图片进行二次压缩。作品历史、生成结果、画廊、全屏预览和下载应始终使用原始结果链接或持久化文件路径。
|
||||||
|
|
||||||
|
## 健康检查与运维命令
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:5100/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
查看 PM2:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 list
|
||||||
|
pm2 logs miaojing-api --lines 100
|
||||||
|
pm2 logs miaojing-web --lines 100
|
||||||
|
pm2 logs miaojing-console --lines 100
|
||||||
|
```
|
||||||
|
|
||||||
|
重启服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run pm2:restart
|
||||||
|
```
|
||||||
|
|
||||||
|
保存 PM2 开机配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run pm2:save
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
- 生产环境必须设置高强度 `DATA_ENCRYPTION_KEY`、`JWT_SECRET`、`GENERATION_INTERNAL_SECRET`。
|
||||||
|
- `.env.local` 不应提交到 git。
|
||||||
|
- 生产环境不要开启 `ENABLE_DANGER_ADMIN_CLEAR_USERS`。
|
||||||
|
- `LOCAL_STORAGE_DIR`、`BACKUP_DIR`、`UPGRADE_STATE_DIR` 建议放在项目目录外。
|
||||||
|
- 升级包不要包含 `.env`、数据库 dump、用户上传文件或备份目录。
|
||||||
|
- 对外暴露时建议在 Nginx/Caddy 后面启用 HTTPS。
|
||||||
|
- 管理后台账号应使用强密码,并限制管理员数量。
|
||||||
|
- 定期执行 `backup:create` 并把备份复制到异地。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 构建时提示缺少 devDependencies
|
||||||
|
|
||||||
|
构建需要 devDependencies。部署或 CI 中不要只安装生产依赖,推荐:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm install --frozen-lockfile --prod=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 管理后台修改后刷新丢失
|
||||||
|
|
||||||
|
优先检查对应接口是否成功写入数据库,再检查 `.env.local` 是否连接到了正确数据库。不要只看前端本地状态。
|
||||||
|
|
||||||
|
### 3. 图片下载尺寸不符合预期
|
||||||
|
|
||||||
|
排查顺序:
|
||||||
|
|
||||||
|
1. 检查上游请求参数是否为目标分辨率。
|
||||||
|
2. 检查生成任务结果中保存的原始文件尺寸。
|
||||||
|
3. 检查 `/api/download` 是否直接返回原始文件。
|
||||||
|
4. 检查前端是否使用缩略图地址下载。
|
||||||
|
|
||||||
|
### 4. 冷更新后页面仍是旧版本
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 list
|
||||||
|
pm2 describe miaojing-api
|
||||||
|
pm2 describe miaojing-web
|
||||||
|
pm2 describe miaojing-console
|
||||||
|
```
|
||||||
|
|
||||||
|
确认 PM2 的 `cwd` 是当前部署目录,并确认构建产物来自同一目录。
|
||||||
|
|
||||||
|
### 5. 数据恢复后作品图片丢失
|
||||||
|
|
||||||
|
确认备份包内是否包含 `local-storage`,并确认 `.env.local` 中的 `LOCAL_STORAGE_DIR` 指向恢复后的存储目录。
|
||||||
|
|
||||||
|
## 版本管理
|
||||||
|
|
||||||
|
推荐使用 `main` 作为稳定分支:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://172.16.10.127:3000/fenglee/miaojingAI.git
|
||||||
|
git checkout main
|
||||||
|
```
|
||||||
|
|
||||||
|
提交前建议执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm run ts-check
|
||||||
|
corepack pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
当前项目为私有项目,未声明开源许可证。未经授权请勿公开分发、复制或商业使用。
|
||||||
|
|||||||
BIN
docs/images/create.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/images/gallery.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/images/home.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
@@ -8,6 +8,7 @@
|
|||||||
"backup:create": "bash ./scripts/backup-create.sh",
|
"backup:create": "bash ./scripts/backup-create.sh",
|
||||||
"backup:list": "bash ./scripts/backup-list.sh",
|
"backup:list": "bash ./scripts/backup-list.sh",
|
||||||
"backup:restore": "bash ./scripts/backup-restore.sh",
|
"backup:restore": "bash ./scripts/backup-restore.sh",
|
||||||
|
"upgrade:run": "node ./scripts/admin-upgrade-runner.mjs",
|
||||||
"db:patch": "bash ./scripts/apply-database-patch.sh",
|
"db:patch": "bash ./scripts/apply-database-patch.sh",
|
||||||
"dev": "bash ./scripts/dev.sh",
|
"dev": "bash ./scripts/dev.sh",
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -75,7 +76,9 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
|
"ag-psd": "^30.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-dev-inspector/babel-plugin": "^2.0.1",
|
"@react-dev-inspector/babel-plugin": "^2.0.1",
|
||||||
|
|||||||
166
pnpm-lock.yaml
generated
@@ -105,6 +105,12 @@ importers:
|
|||||||
'@supabase/supabase-js':
|
'@supabase/supabase-js':
|
||||||
specifier: 2.95.3
|
specifier: 2.95.3
|
||||||
version: 2.95.3
|
version: 2.95.3
|
||||||
|
'@xyflow/react':
|
||||||
|
specifier: ^12.10.2
|
||||||
|
version: 12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
ag-psd:
|
||||||
|
specifier: ^30.1.1
|
||||||
|
version: 30.1.1
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -1403,24 +1409,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.2.4':
|
'@next/swc-linux-arm64-musl@16.2.4':
|
||||||
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.2.4':
|
'@next/swc-linux-x64-gnu@16.2.4':
|
||||||
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.2.4':
|
'@next/swc-linux-x64-musl@16.2.4':
|
||||||
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.2.4':
|
'@next/swc-win32-arm64-msvc@16.2.4':
|
||||||
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
|
||||||
@@ -2638,6 +2648,9 @@ packages:
|
|||||||
'@types/d3-color@3.1.3':
|
'@types/d3-color@3.1.3':
|
||||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
'@types/d3-ease@3.0.2':
|
'@types/d3-ease@3.0.2':
|
||||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
@@ -2650,6 +2663,9 @@ packages:
|
|||||||
'@types/d3-scale@4.0.9':
|
'@types/d3-scale@4.0.9':
|
||||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11':
|
||||||
|
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||||
|
|
||||||
'@types/d3-shape@3.1.8':
|
'@types/d3-shape@3.1.8':
|
||||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||||
|
|
||||||
@@ -2659,6 +2675,12 @@ packages:
|
|||||||
'@types/d3-timer@3.0.2':
|
'@types/d3-timer@3.0.2':
|
||||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
@@ -2833,41 +2855,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||||
@@ -2940,6 +2970,15 @@ packages:
|
|||||||
'@xtuc/long@4.2.2':
|
'@xtuc/long@4.2.2':
|
||||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.2':
|
||||||
|
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.76':
|
||||||
|
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2969,6 +3008,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
ag-psd@30.1.1:
|
||||||
|
resolution: {integrity: sha512-0GbWYR4Rvm1QnWCYeMiVbUJBXnSyTUKvNUK2tIIVDt/wrUVUL9pHTsnwqOTonEC2RRh5I/aUcGydc1LNgXfJWA==}
|
||||||
|
|
||||||
agent-base@7.1.4:
|
agent-base@7.1.4:
|
||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -3252,6 +3294,9 @@ packages:
|
|||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
classcat@5.0.5:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3388,6 +3433,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-ease@3.0.1:
|
d3-ease@3.0.1:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3408,6 +3461,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3424,6 +3481,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-transition@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
d3-selection: 2 - 3
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8:
|
damerau-levenshtein@1.0.8:
|
||||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||||
|
|
||||||
@@ -5245,6 +5312,9 @@ packages:
|
|||||||
package-manager-detector@1.6.0:
|
package-manager-detector@1.6.0:
|
||||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||||
|
|
||||||
|
pako@2.1.0:
|
||||||
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -6368,6 +6438,21 @@ packages:
|
|||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
|
zustand@4.5.7:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
zwitch@2.0.4:
|
zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
@@ -9034,6 +9119,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-color@3.1.3': {}
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/d3-ease@3.0.2': {}
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
'@types/d3-interpolate@3.0.4':
|
'@types/d3-interpolate@3.0.4':
|
||||||
@@ -9046,6 +9135,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-time': 3.0.4
|
'@types/d3-time': 3.0.4
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11': {}
|
||||||
|
|
||||||
'@types/d3-shape@3.1.8':
|
'@types/d3-shape@3.1.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-path': 3.1.1
|
'@types/d3-path': 3.1.1
|
||||||
@@ -9054,6 +9145,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-timer@3.0.2': {}
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
@@ -9362,6 +9462,29 @@ snapshots:
|
|||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.2(@types/react@19.2.10)(immer@9.0.21)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@xyflow/system': 0.0.76
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
zustand: 4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.76':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-drag': 3.0.7
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
'@types/d3-transition': 3.0.9
|
||||||
|
'@types/d3-zoom': 3.0.8
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-zoom: 3.0.0
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
@@ -9381,6 +9504,11 @@ snapshots:
|
|||||||
|
|
||||||
address@1.2.2: {}
|
address@1.2.2: {}
|
||||||
|
|
||||||
|
ag-psd@30.1.1:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
pako: 2.1.0
|
||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
ajv-formats@2.1.1(ajv@8.18.0):
|
ajv-formats@2.1.1(ajv@8.18.0):
|
||||||
@@ -9686,6 +9814,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
classcat@5.0.5: {}
|
||||||
|
|
||||||
cli-cursor@5.0.0:
|
cli-cursor@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 5.1.0
|
restore-cursor: 5.1.0
|
||||||
@@ -9812,6 +9942,13 @@ snapshots:
|
|||||||
|
|
||||||
d3-color@3.1.0: {}
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
|
||||||
d3-ease@3.0.1: {}
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
d3-format@3.1.2: {}
|
d3-format@3.1.2: {}
|
||||||
@@ -9830,6 +9967,8 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-time-format: 4.1.0
|
d3-time-format: 4.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
d3-shape@3.2.0:
|
d3-shape@3.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-path: 3.1.0
|
d3-path: 3.1.0
|
||||||
@@ -9844,6 +9983,23 @@ snapshots:
|
|||||||
|
|
||||||
d3-timer@3.0.1: {}
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||||
|
|
||||||
damerau-levenshtein@1.0.8: {}
|
damerau-levenshtein@1.0.8: {}
|
||||||
|
|
||||||
data-uri-to-buffer@4.0.1: {}
|
data-uri-to-buffer@4.0.1: {}
|
||||||
@@ -11947,6 +12103,8 @@ snapshots:
|
|||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
|
pako@2.1.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -13333,4 +13491,12 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@19.2.10)(immer@9.0.21)(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.10
|
||||||
|
immer: 9.0.21
|
||||||
|
react: 19.2.3
|
||||||
|
|
||||||
zwitch@2.0.4: {}
|
zwitch@2.0.4: {}
|
||||||
|
|||||||
BIN
public/canvas/workflows/scene01.jpeg
Executable file
|
After Width: | Height: | Size: 898 KiB |
BIN
public/canvas/workflows/shot01.jpeg
Executable file
|
After Width: | Height: | Size: 936 KiB |
BIN
public/canvas/workflows/workflow01.jpeg
Executable file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/canvas/workflows/workflow02.jpeg
Executable file
|
After Width: | Height: | Size: 902 KiB |
564
scripts/admin-upgrade-runner.mjs
Executable file
@@ -0,0 +1,564 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const projectRoot = path.resolve(args.project || process.cwd());
|
||||||
|
loadEnvFile(path.join(projectRoot, '.env.local'));
|
||||||
|
|
||||||
|
const stateRoot = path.resolve(
|
||||||
|
process.env.UPGRADE_STATE_DIR ||
|
||||||
|
(process.env.LOCAL_STORAGE_DIR ? path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade') : path.join(projectRoot, 'upgrade-state')),
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobId = requireArg(args, 'job-id');
|
||||||
|
const mode = requireArg(args, 'mode');
|
||||||
|
const dryRun = args['dry-run'] === 'true';
|
||||||
|
const packagePath = path.resolve(requireArg(args, 'package'));
|
||||||
|
const packageName = args['package-name'] || path.basename(packagePath);
|
||||||
|
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
||||||
|
const stateFile = path.join(jobDir, 'state.json');
|
||||||
|
const extractDir = path.join(jobDir, 'extract');
|
||||||
|
const sourceBackupFile = path.join(jobDir, `source-before-${jobId}.tar.gz`);
|
||||||
|
|
||||||
|
const HOT_ALLOWED_PREFIXES = ['public/'];
|
||||||
|
const HOT_ALLOWED_FILES = new Set([
|
||||||
|
'manifest.json',
|
||||||
|
'robots.txt',
|
||||||
|
'sitemap.xml',
|
||||||
|
'favicon.ico',
|
||||||
|
'icon.png',
|
||||||
|
'apple-icon.png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const COLD_ALLOWED_PREFIXES = ['src/', 'public/', 'scripts/', 'database/', 'docs/'];
|
||||||
|
const COLD_ALLOWED_FILES = new Set([
|
||||||
|
'manifest.json',
|
||||||
|
'package.json',
|
||||||
|
'pnpm-lock.yaml',
|
||||||
|
'next.config.js',
|
||||||
|
'next.config.mjs',
|
||||||
|
'next.config.ts',
|
||||||
|
'tsconfig.json',
|
||||||
|
'postcss.config.mjs',
|
||||||
|
'components.json',
|
||||||
|
'ecosystem.config.cjs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BLOCKED_TOP_LEVEL_NAMES = new Set(['.git', 'node_modules', '.next', 'dist', 'backups', 'local-storage', 'upgrade-state']);
|
||||||
|
const BLOCKED_ANYWHERE_NAMES = new Set(['.git', 'node_modules', '.next']);
|
||||||
|
|
||||||
|
let state = readState() || {
|
||||||
|
id: jobId,
|
||||||
|
mode,
|
||||||
|
status: 'queued',
|
||||||
|
step: 'queued',
|
||||||
|
message: '升级任务已创建',
|
||||||
|
progress: 0,
|
||||||
|
packageName,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
logs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
log(`fatal: ${error instanceof Error ? error.stack || error.message : String(error)}`);
|
||||||
|
if (dryRun) {
|
||||||
|
updateState({
|
||||||
|
status: 'failed',
|
||||||
|
step: 'preflight_failed',
|
||||||
|
progress: 100,
|
||||||
|
message: '升级包预检失败,请按错误信息调整升级包',
|
||||||
|
error: error instanceof Error ? error.message : '升级包预检异常退出',
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rollbackAfterFailure(error instanceof Error ? error.message : '升级任务异常退出').catch(rollbackError => {
|
||||||
|
updateState({
|
||||||
|
status: 'rollback_failed',
|
||||||
|
step: 'rollback_failed',
|
||||||
|
progress: 100,
|
||||||
|
message: '升级失败,自动回滚也失败,请立即人工检查',
|
||||||
|
error: `${error instanceof Error ? error.message : String(error)}; rollback: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
ensureDir(jobDir);
|
||||||
|
updateState({
|
||||||
|
status: 'running',
|
||||||
|
step: 'preflight',
|
||||||
|
progress: 5,
|
||||||
|
message: '正在检查升级包与运行环境',
|
||||||
|
startedAt: state.startedAt || new Date().toISOString(),
|
||||||
|
});
|
||||||
|
logStep('开始升级任务', `任务 ${jobId} 使用${mode === 'hot' ? '热更新' : '冷更新'}模式,升级包 ${packageName}${dryRun ? ',仅执行预检' : ''}`);
|
||||||
|
|
||||||
|
if (mode !== 'hot' && mode !== 'cold') {
|
||||||
|
throw new Error('升级方式无效');
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(packagePath)) {
|
||||||
|
throw new Error(`升级包不存在: ${packagePath}`);
|
||||||
|
}
|
||||||
|
if (!isAllowedArchive(packageName) && !isAllowedArchive(packagePath)) {
|
||||||
|
throw new Error('仅支持 .tar、.tar.gz、.tgz 升级包');
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep('校验升级包', '正在读取压缩包目录并检查格式');
|
||||||
|
run('tar', tarReadArgs('list', packagePath), { cwd: projectRoot, label: '检查升级包结构' });
|
||||||
|
|
||||||
|
resetDir(extractDir);
|
||||||
|
run('tar', [...tarReadArgs('extract', packagePath), '-C', extractDir], { cwd: projectRoot, label: '解压升级包' });
|
||||||
|
|
||||||
|
const payloadRoot = resolvePayloadRoot(extractDir);
|
||||||
|
const files = listFiles(payloadRoot);
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error('升级包为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateFiles(files, mode);
|
||||||
|
logStep('升级包内容', `校验通过,共 ${files.length} 个文件:${files.slice(0, 20).join('、')}${files.length > 20 ? ` 等 ${files.length} 个文件` : ''}`);
|
||||||
|
updateState({
|
||||||
|
step: 'validated',
|
||||||
|
progress: 14,
|
||||||
|
message: `升级包校验通过,共 ${files.length} 个文件`,
|
||||||
|
restartRequired: mode === 'cold' || validation.requiresRestart,
|
||||||
|
packageHash: sha256(packagePath),
|
||||||
|
changedFiles: files,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
logStep('预检完成', `升级包可用于${mode === 'hot' ? '热更新' : '冷更新'},${mode === 'cold' || validation.requiresRestart ? '需要重启平台' : '无需重启平台'}`);
|
||||||
|
updateState({
|
||||||
|
status: 'succeeded',
|
||||||
|
step: 'preflight_completed',
|
||||||
|
progress: 100,
|
||||||
|
message: `预检通过:共 ${files.length} 个文件,${mode === 'cold' || validation.requiresRestart ? '执行时需要重启平台' : '执行时无需重启平台'}`,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
restartRequired: mode === 'cold' || validation.requiresRestart,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({ step: 'backup_data', progress: 22, message: '正在创建数据库、存储与环境配置备份' });
|
||||||
|
logStep('创建数据备份', '开始备份数据库、存储目录和环境配置');
|
||||||
|
const backupFile = runCapture('bash', ['./scripts/backup-create.sh'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
label: '创建数据备份',
|
||||||
|
env: { BACKUP_DIR: path.join(stateRoot, 'data-backups'), COZE_WORKSPACE_PATH: projectRoot },
|
||||||
|
}).trim().split('\n').pop();
|
||||||
|
if (!backupFile || !fs.existsSync(backupFile)) {
|
||||||
|
throw new Error('数据备份创建失败');
|
||||||
|
}
|
||||||
|
updateState({ backupFile });
|
||||||
|
logStep('数据备份完成', `备份文件:${backupFile}`);
|
||||||
|
|
||||||
|
updateState({ step: 'backup_source', progress: 30, message: '正在创建源码快照' });
|
||||||
|
logStep('创建源码快照', '开始保存升级前源码状态');
|
||||||
|
createSourceBackup(sourceBackupFile);
|
||||||
|
updateState({ sourceBackupFile });
|
||||||
|
logStep('源码快照完成', `快照文件:${sourceBackupFile}`);
|
||||||
|
|
||||||
|
updateState({ step: 'apply', progress: 42, message: '正在应用升级包文件' });
|
||||||
|
logStep('应用升级文件', '开始覆盖升级包中的文件');
|
||||||
|
updateState({ preExistingFiles: files.filter(file => fs.existsSync(path.join(projectRoot, file))) });
|
||||||
|
applyFiles(payloadRoot, files);
|
||||||
|
logStep('升级文件应用完成', `已应用 ${files.filter(file => file !== 'manifest.json').length} 个文件`);
|
||||||
|
|
||||||
|
if (mode === 'hot') {
|
||||||
|
updateState({ step: 'verify_hot', progress: 70, message: '正在验证热更新文件' });
|
||||||
|
logStep('热更新验证', '正在执行 TypeScript 校验,确认补丁不会破坏现有代码');
|
||||||
|
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
|
||||||
|
logStep('热更新完成', '升级成功,平台未重启,前端业务不中断');
|
||||||
|
updateState({
|
||||||
|
status: 'succeeded',
|
||||||
|
step: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
message: '热更新成功,平台未重启',
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
restartRequired: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependencyChanged = files.some(file => file === 'package.json' || file === 'pnpm-lock.yaml');
|
||||||
|
if (dependencyChanged) {
|
||||||
|
updateState({ step: 'install', progress: 54, message: '依赖文件发生变化,正在安装依赖' });
|
||||||
|
logStep('安装依赖', '检测到 package.json 或 pnpm-lock.yaml 变化,开始安装依赖');
|
||||||
|
run('pnpm', ['install', '--frozen-lockfile', '--prod=false'], { cwd: projectRoot, label: '安装依赖' });
|
||||||
|
logStep('依赖安装完成', '依赖安装已完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({ step: 'ts_check', progress: 64, message: '正在执行 TypeScript 校验' });
|
||||||
|
logStep('代码校验', '开始执行 TypeScript 校验');
|
||||||
|
run('pnpm', ['run', 'ts-check'], { cwd: projectRoot, label: 'TypeScript 校验' });
|
||||||
|
logStep('代码校验完成', 'TypeScript 校验已通过');
|
||||||
|
|
||||||
|
updateState({ step: 'build', progress: 75, message: '正在构建平台' });
|
||||||
|
logStep('平台构建', '开始构建生产版本');
|
||||||
|
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '构建平台' });
|
||||||
|
logStep('平台构建完成', '生产构建已完成');
|
||||||
|
|
||||||
|
updateState({ step: 'restart', progress: 94, message: '构建已完成,正在后台重启平台进程' });
|
||||||
|
logStep('冷更新完成', '升级文件已应用并完成构建,将在后台重启平台进程');
|
||||||
|
updateState({
|
||||||
|
status: 'succeeded',
|
||||||
|
step: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
message: '冷更新成功,平台正在后台重启',
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
restartRequired: true,
|
||||||
|
});
|
||||||
|
restartPlatform({ detached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackAfterFailure(message) {
|
||||||
|
const originalError = message;
|
||||||
|
logStep('升级失败', `失败原因:${originalError}`);
|
||||||
|
updateState({
|
||||||
|
status: 'rolling_back',
|
||||||
|
step: 'rolling_back',
|
||||||
|
progress: 96,
|
||||||
|
message: '升级失败,正在自动回滚到升级前状态',
|
||||||
|
error: originalError,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fs.existsSync(sourceBackupFile)) {
|
||||||
|
logStep('回滚源码', '正在恢复升级前源码快照,并移除升级中新建的文件');
|
||||||
|
restoreSourceBackup(sourceBackupFile);
|
||||||
|
logStep('源码回滚完成', '源码已恢复到升级开始前状态');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.backupFile && fs.existsSync(state.backupFile)) {
|
||||||
|
logStep('回滚数据', '正在恢复数据库、存储目录和环境配置备份');
|
||||||
|
run('bash', ['./scripts/backup-restore.sh', state.backupFile], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
label: '恢复数据备份',
|
||||||
|
env: { COZE_WORKSPACE_PATH: projectRoot },
|
||||||
|
});
|
||||||
|
logStep('数据回滚完成', '数据库、存储目录和环境配置已恢复');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'cold') {
|
||||||
|
try {
|
||||||
|
logStep('回滚后重建', '冷更新失败后正在重新构建回滚版本');
|
||||||
|
run('pnpm', ['run', 'build'], { cwd: projectRoot, label: '回滚后重新构建' });
|
||||||
|
logStep('回滚后重启', '将后台重启回滚后的平台版本');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`回滚后平台恢复检查失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep('自动回滚完成', '升级失败,但已自动恢复到升级开始前状态');
|
||||||
|
updateState({
|
||||||
|
status: 'rolled_back',
|
||||||
|
step: 'rolled_back',
|
||||||
|
progress: 100,
|
||||||
|
message: '升级失败,已自动回滚到升级开始前状态',
|
||||||
|
error: originalError,
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (mode === 'cold') {
|
||||||
|
restartPlatform({ detached: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const parsed = {};
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const item = argv[index];
|
||||||
|
if (!item.startsWith('--')) continue;
|
||||||
|
const key = item.slice(2);
|
||||||
|
const next = argv[index + 1];
|
||||||
|
if (!next || next.startsWith('--')) {
|
||||||
|
parsed[key] = 'true';
|
||||||
|
} else {
|
||||||
|
parsed[key] = next;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireArg(parsed, key) {
|
||||||
|
const value = parsed[key];
|
||||||
|
if (!value) throw new Error(`missing --${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvFile(file) {
|
||||||
|
if (!fs.existsSync(file)) return;
|
||||||
|
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
||||||
|
const index = trimmed.indexOf('=');
|
||||||
|
const key = trimmed.slice(0, index).trim();
|
||||||
|
let value = trimmed.slice(index + 1).trim();
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (!process.env[key]) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateState(patch) {
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
logs: patch.logs || state.logs || [],
|
||||||
|
};
|
||||||
|
ensureDir(path.dirname(stateFile));
|
||||||
|
const tempFile = `${stateFile}.tmp`;
|
||||||
|
fs.writeFileSync(tempFile, `${JSON.stringify(state, null, 2)}\n`);
|
||||||
|
fs.renameSync(tempFile, stateFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(line) {
|
||||||
|
const timestamped = `[${new Date().toISOString()}] ${line}`;
|
||||||
|
const logs = [...(state.logs || []), timestamped].slice(-1000);
|
||||||
|
updateState({ logs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStep(title, detail = '') {
|
||||||
|
log(detail ? `${title}:${detail}` : title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDir(dir) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
ensureDir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, commandArgs, options = {}) {
|
||||||
|
runCapture(command, commandArgs, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCapture(command, commandArgs, options = {}) {
|
||||||
|
const label = options.label || command;
|
||||||
|
logStep(label, `执行命令 ${command} ${commandArgs.join(' ')}`);
|
||||||
|
const result = spawnSync(command, commandArgs, {
|
||||||
|
cwd: options.cwd || projectRoot,
|
||||||
|
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack', ...(options.env || {}) },
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 20 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
||||||
|
if (output) {
|
||||||
|
for (const line of output.split(/\r?\n/).slice(-180)) log(`${label}输出:${line}`);
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${label}失败,退出码 ${result.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
return result.stdout || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedArchive(file) {
|
||||||
|
return file.endsWith('.tar') || file.endsWith('.tar.gz') || file.endsWith('.tgz');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePayloadRoot(root) {
|
||||||
|
const entries = fs.readdirSync(root, { withFileTypes: true }).filter(entry => entry.name !== '__MACOSX');
|
||||||
|
if (entries.length === 1 && entries[0].isDirectory()) {
|
||||||
|
return path.join(root, entries[0].name);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFiles(root) {
|
||||||
|
const files = [];
|
||||||
|
walk(root, '');
|
||||||
|
return files.sort();
|
||||||
|
|
||||||
|
function walk(currentRoot, relativeRoot) {
|
||||||
|
for (const entry of fs.readdirSync(currentRoot, { withFileTypes: true })) {
|
||||||
|
if (entry.name === '.DS_Store') continue;
|
||||||
|
const relative = toPosix(path.join(relativeRoot, entry.name));
|
||||||
|
const absolute = path.join(currentRoot, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(absolute, relative);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(relative);
|
||||||
|
} else {
|
||||||
|
throw new Error(`升级包包含不支持的文件类型: ${relative}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFiles(files, updateMode) {
|
||||||
|
for (const file of files) {
|
||||||
|
assertSafeRelativePath(file);
|
||||||
|
if (isBlockedPackagePath(file)) {
|
||||||
|
throw new Error(`升级包包含禁止覆盖的路径: ${file}`);
|
||||||
|
}
|
||||||
|
if (updateMode === 'hot' && !isHotAllowed(file)) {
|
||||||
|
throw new Error(`热更新只能包含 public 等无需重启的静态资源;${file} 需要使用冷更新`);
|
||||||
|
}
|
||||||
|
if (updateMode === 'cold' && !isColdAllowed(file)) {
|
||||||
|
throw new Error(`冷更新包包含未授权路径: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { requiresRestart: files.some(file => !isHotAllowed(file)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedPackagePath(file) {
|
||||||
|
const parts = file.split('/');
|
||||||
|
return (
|
||||||
|
parts.some(part => part.startsWith('.env')) ||
|
||||||
|
BLOCKED_TOP_LEVEL_NAMES.has(parts[0]) ||
|
||||||
|
parts.some(part => BLOCKED_ANYWHERE_NAMES.has(part))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeRelativePath(file) {
|
||||||
|
if (!file || file.startsWith('/') || file.startsWith('\\') || file.includes('\\')) {
|
||||||
|
throw new Error(`升级包包含非法路径: ${file}`);
|
||||||
|
}
|
||||||
|
const normalized = path.posix.normalize(file);
|
||||||
|
if (normalized !== file || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
||||||
|
throw new Error(`升级包包含目录穿越路径: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHotAllowed(file) {
|
||||||
|
return HOT_ALLOWED_FILES.has(file) || HOT_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColdAllowed(file) {
|
||||||
|
return COLD_ALLOWED_FILES.has(file) || COLD_ALLOWED_PREFIXES.some(prefix => file.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFiles(root, files) {
|
||||||
|
for (const file of files) {
|
||||||
|
if (file === 'manifest.json') continue;
|
||||||
|
const source = path.join(root, file);
|
||||||
|
const target = path.join(projectRoot, file);
|
||||||
|
ensureDir(path.dirname(target));
|
||||||
|
fs.copyFileSync(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourceBackup(target) {
|
||||||
|
ensureDir(path.dirname(target));
|
||||||
|
run('tar', [
|
||||||
|
'-czf',
|
||||||
|
target,
|
||||||
|
'--exclude=.git',
|
||||||
|
'--exclude=node_modules',
|
||||||
|
'--exclude=.next',
|
||||||
|
'--exclude=dist',
|
||||||
|
'--exclude=backups',
|
||||||
|
'--exclude=local-storage',
|
||||||
|
'--exclude=upgrade-state',
|
||||||
|
'--exclude=tsconfig.tsbuildinfo',
|
||||||
|
'-C',
|
||||||
|
projectRoot,
|
||||||
|
'.',
|
||||||
|
], { cwd: projectRoot, label: '创建源码快照' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSourceBackup(source) {
|
||||||
|
log(`恢复源码快照: ${source}`);
|
||||||
|
const preExistingFiles = new Set(Array.isArray(state.preExistingFiles) ? state.preExistingFiles : []);
|
||||||
|
const changedFiles = Array.isArray(state.changedFiles) ? state.changedFiles : [];
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
if (file === 'manifest.json' || preExistingFiles.has(file)) continue;
|
||||||
|
const target = path.join(projectRoot, file);
|
||||||
|
if (target.startsWith(projectRoot)) {
|
||||||
|
fs.rmSync(target, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run('tar', [
|
||||||
|
'-xzf',
|
||||||
|
source,
|
||||||
|
'--exclude=.git',
|
||||||
|
'--exclude=node_modules',
|
||||||
|
'--exclude=.next',
|
||||||
|
'--exclude=dist',
|
||||||
|
'-C',
|
||||||
|
projectRoot,
|
||||||
|
], { cwd: projectRoot, label: '恢复源码快照' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPlatform(options = {}) {
|
||||||
|
const restartCommand = process.env.UPGRADE_RESTART_COMMAND || detectRestartCommand();
|
||||||
|
if (options.detached) {
|
||||||
|
const logFile = path.join(jobDir, 'restart.log');
|
||||||
|
const detachedCommand = `nohup bash -lc ${JSON.stringify(restartCommand)} >> ${JSON.stringify(logFile)} 2>&1 &`;
|
||||||
|
spawnSync('bash', ['-lc', detachedCommand], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: { ...process.env, COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack' },
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
logStep('后台重启平台', `已触发后台重启命令,日志:${logFile}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run('bash', ['-lc', restartCommand], { cwd: projectRoot, label: '重启平台' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectRestartCommand() {
|
||||||
|
const pm2Names = runCapture('bash', ['-lc', 'command -v pm2 >/dev/null 2>&1 && pm2 jlist || true'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
label: '检测 PM2 进程',
|
||||||
|
});
|
||||||
|
if (pm2Names.includes('"name":"miaojing-dev"')) return 'pm2 restart miaojing-dev --update-env';
|
||||||
|
if (fs.existsSync(path.join(projectRoot, 'ecosystem.config.cjs'))) return 'pm2 startOrReload ecosystem.config.cjs --update-env && pm2 save';
|
||||||
|
return 'pm2 restart miaojing-dev --update-env';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tarReadArgs(action, archivePath) {
|
||||||
|
const flag = action === 'list' ? '-tf' : '-xf';
|
||||||
|
const gzipFlag = action === 'list' ? '-tzf' : '-xzf';
|
||||||
|
return archivePath.endsWith('.tar') ? [flag, archivePath] : [gzipFlag, archivePath];
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForHealth() {
|
||||||
|
const healthUrl = process.env.UPGRADE_HEALTH_URL || process.env.APP_HEALTH_URL || 'http://127.0.0.1:5100/api/health';
|
||||||
|
const timeoutMs = Number(process.env.UPGRADE_HEALTH_TIMEOUT_MS || 90000);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let lastError = '';
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
const result = spawnSync('curl', ['-fsS', healthUrl], { encoding: 'utf8', timeout: 8000 });
|
||||||
|
if (result.status === 0) {
|
||||||
|
log(`健康检查通过: ${healthUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = `${result.stderr || result.stdout || `exit ${result.status}`}`.trim();
|
||||||
|
sleep(3000);
|
||||||
|
}
|
||||||
|
throw new Error(`健康检查超时: ${healthUrl}; ${lastError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(file) {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
hash.update(fs.readFileSync(file));
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPosix(file) {
|
||||||
|
return file.split(path.sep).join('/');
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ type ImportContext = {
|
|||||||
apiKeyIdMap: Map<string, string>;
|
apiKeyIdMap: Map<string, string>;
|
||||||
apiKeyOwnerIdMap: Map<string, string>;
|
apiKeyOwnerIdMap: Map<string, string>;
|
||||||
columnCache: Map<string, Set<string>>;
|
columnCache: Map<string, Set<string>>;
|
||||||
|
defaultableColumnCache: Map<string, Set<string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -136,11 +137,15 @@ async function importRows(
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const existingColumns = await getExistingColumns(client, table, context);
|
const existingColumns = await getExistingColumns(client, table, context);
|
||||||
|
const defaultableColumns = await getDefaultableColumns(client, table, context);
|
||||||
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
|
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
|
||||||
|
|
||||||
for (const rawRow of rows) {
|
for (const rawRow of rows) {
|
||||||
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
||||||
const cols = Object.keys(row).filter(col => effectiveAllowedColumns.includes(col));
|
const cols = Object.keys(row).filter(col => (
|
||||||
|
effectiveAllowedColumns.includes(col)
|
||||||
|
&& !(row[col] == null && defaultableColumns.has(col))
|
||||||
|
));
|
||||||
if (!cols.includes('id') || cols.length === 0) {
|
if (!cols.includes('id') || cols.length === 0) {
|
||||||
skipped++;
|
skipped++;
|
||||||
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
||||||
@@ -235,7 +240,15 @@ async function buildImportContext(
|
|||||||
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
|
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
|
||||||
}
|
}
|
||||||
const ownerId = findImportedWorkUserId(row);
|
const ownerId = findImportedWorkUserId(row);
|
||||||
const ownerByEmail = findUserIdByEmail(row, { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() });
|
const ownerByEmail = findUserIdByEmail(row, {
|
||||||
|
userIdMap,
|
||||||
|
workIdMap,
|
||||||
|
emailUserIdMap,
|
||||||
|
apiKeyIdMap,
|
||||||
|
apiKeyOwnerIdMap,
|
||||||
|
columnCache: new Map(),
|
||||||
|
defaultableColumnCache: new Map(),
|
||||||
|
});
|
||||||
const mappedOwnerId = ownerId
|
const mappedOwnerId = ownerId
|
||||||
? (userIdMap.get(ownerId) || ownerId)
|
? (userIdMap.get(ownerId) || ownerId)
|
||||||
: ownerByEmail;
|
: ownerByEmail;
|
||||||
@@ -266,7 +279,15 @@ async function buildImportContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() };
|
return {
|
||||||
|
userIdMap,
|
||||||
|
workIdMap,
|
||||||
|
emailUserIdMap,
|
||||||
|
apiKeyIdMap,
|
||||||
|
apiKeyOwnerIdMap,
|
||||||
|
columnCache: new Map(),
|
||||||
|
defaultableColumnCache: new Map(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
|
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
|
||||||
@@ -331,6 +352,12 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (table === 'user_api_keys') {
|
if (table === 'user_api_keys') {
|
||||||
|
if (typeof next.note !== 'string' || next.note.trim() === '') {
|
||||||
|
next.note = '导入的 API Key';
|
||||||
|
}
|
||||||
|
if (typeof next.type !== 'string' || next.type.trim() === '') {
|
||||||
|
next.type = 'image';
|
||||||
|
}
|
||||||
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
|
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
|
||||||
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
|
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
|
||||||
const secret = rawApiKey || rawEncrypted;
|
const secret = rawApiKey || rawEncrypted;
|
||||||
@@ -519,6 +546,29 @@ async function getExistingColumns(
|
|||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getDefaultableColumns(
|
||||||
|
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||||
|
table: string,
|
||||||
|
context: ImportContext,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const cached = context.defaultableColumnCache.get(table);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [schemaName, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1
|
||||||
|
AND table_name = $2
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
AND column_default IS NOT NULL`,
|
||||||
|
[schemaName, tableName],
|
||||||
|
);
|
||||||
|
const columns = new Set((result.rows || []).map((row: Record<string, unknown>) => String(row.column_name)));
|
||||||
|
context.defaultableColumnCache.set(table, columns);
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
function seedUuidMap(map: Map<string, string>, value: unknown): void {
|
function seedUuidMap(map: Map<string, string>, value: unknown): void {
|
||||||
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
|
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
|
||||||
map.set(value, crypto.randomUUID());
|
map.set(value, crypto.randomUUID());
|
||||||
|
|||||||
207
src/app/api/admin/upgrade/route.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdmin } from '@/lib/admin-auth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
type UpgradeMode = 'hot' | 'cold';
|
||||||
|
type UpgradeStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'rolling_back'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'rolled_back'
|
||||||
|
| 'rollback_failed';
|
||||||
|
|
||||||
|
type UpgradeJobState = {
|
||||||
|
id: string;
|
||||||
|
mode: UpgradeMode;
|
||||||
|
status: UpgradeStatus;
|
||||||
|
step: string;
|
||||||
|
message: string;
|
||||||
|
progress: number;
|
||||||
|
packageName: string;
|
||||||
|
packageHash?: string;
|
||||||
|
backupFile?: string;
|
||||||
|
sourceBackupFile?: string;
|
||||||
|
restartRequired?: boolean;
|
||||||
|
changedFiles?: string[];
|
||||||
|
preExistingFiles?: string[];
|
||||||
|
error?: string;
|
||||||
|
startedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
logs: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_PACKAGE_BYTES = 300 * 1024 * 1024;
|
||||||
|
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = await requireAdmin(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const states = await readStates();
|
||||||
|
return NextResponse.json({
|
||||||
|
latest: states[0] || null,
|
||||||
|
history: states,
|
||||||
|
stateDir: getUpgradeStateRoot(),
|
||||||
|
running: states.some(job => RUNNING_STATUSES.has(job.status)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[admin/upgrade] failed to read state:', error);
|
||||||
|
return NextResponse.json({ error: '读取升级状态失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authError = await requireAdmin(request);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const states = await readStates();
|
||||||
|
const runningJob = states.find(job => RUNNING_STATUSES.has(job.status));
|
||||||
|
if (runningJob) {
|
||||||
|
return NextResponse.json({ error: `已有升级任务正在执行:${runningJob.id}` }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const modeValue = String(form.get('mode') || '');
|
||||||
|
const mode = modeValue === 'hot' || modeValue === 'cold' ? modeValue : null;
|
||||||
|
const dryRun = String(form.get('dryRun') || '') === 'true';
|
||||||
|
if (!mode) {
|
||||||
|
return NextResponse.json({ error: '请选择热更新或冷更新' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = form.get('package');
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: '请上传升级包' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size <= 0) {
|
||||||
|
return NextResponse.json({ error: '升级包为空' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_PACKAGE_BYTES) {
|
||||||
|
return NextResponse.json({ error: '升级包不能超过 300MB' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!isAllowedArchiveName(file.name)) {
|
||||||
|
return NextResponse.json({ error: '仅支持 .tar、.tar.gz、.tgz 升级包' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateRoot = getUpgradeStateRoot();
|
||||||
|
const jobId = `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${randomUUID().slice(0, 8)}`;
|
||||||
|
const jobDir = path.join(stateRoot, 'jobs', jobId);
|
||||||
|
const uploadDir = path.join(jobDir, 'upload');
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
const safeName = sanitizeFileName(file.name);
|
||||||
|
const packagePath = path.join(uploadDir, safeName);
|
||||||
|
const bytes = Buffer.from(await file.arrayBuffer());
|
||||||
|
await fs.writeFile(packagePath, bytes, { mode: 0o600 });
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const initialState: UpgradeJobState = {
|
||||||
|
id: jobId,
|
||||||
|
mode,
|
||||||
|
status: 'queued',
|
||||||
|
step: 'queued',
|
||||||
|
message: '升级包已上传,等待执行',
|
||||||
|
progress: 0,
|
||||||
|
packageName: file.name,
|
||||||
|
packageHash: createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
startedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
logs: [`[${now}] 上传升级包 ${file.name} (${file.size} bytes)`],
|
||||||
|
dryRun,
|
||||||
|
};
|
||||||
|
if (dryRun) {
|
||||||
|
initialState.message = '升级包已上传,正在执行预检';
|
||||||
|
}
|
||||||
|
await writeState(jobDir, initialState);
|
||||||
|
|
||||||
|
const runnerArgs = [
|
||||||
|
path.join(process.cwd(), 'scripts/admin-upgrade-runner.mjs'),
|
||||||
|
'--job-id',
|
||||||
|
jobId,
|
||||||
|
'--mode',
|
||||||
|
mode,
|
||||||
|
'--package',
|
||||||
|
packagePath,
|
||||||
|
'--package-name',
|
||||||
|
file.name,
|
||||||
|
'--project',
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
if (dryRun) runnerArgs.push('--dry-run', 'true');
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, runnerArgs, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
UPGRADE_STATE_DIR: stateRoot,
|
||||||
|
COREPACK_HOME: process.env.COREPACK_HOME || '/tmp/corepack',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, dryRun, job: initialState });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[admin/upgrade] failed to start upgrade:', error);
|
||||||
|
return NextResponse.json({ error: error instanceof Error ? error.message : '创建升级任务失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpgradeStateRoot(): string {
|
||||||
|
const configured = process.env.UPGRADE_STATE_DIR;
|
||||||
|
if (configured) return path.resolve(configured);
|
||||||
|
if (process.env.LOCAL_STORAGE_DIR) return path.join(path.dirname(process.env.LOCAL_STORAGE_DIR), 'upgrade');
|
||||||
|
return path.join(process.cwd(), 'upgrade-state');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStates(): Promise<UpgradeJobState[]> {
|
||||||
|
const jobsRoot = path.join(getUpgradeStateRoot(), 'jobs');
|
||||||
|
let jobNames: string[] = [];
|
||||||
|
try {
|
||||||
|
jobNames = await fs.readdir(jobsRoot);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const states = await Promise.all(
|
||||||
|
jobNames.map(async jobName => {
|
||||||
|
try {
|
||||||
|
const statePath = path.join(jobsRoot, jobName, 'state.json');
|
||||||
|
const raw = await fs.readFile(statePath, 'utf8');
|
||||||
|
return JSON.parse(raw) as UpgradeJobState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return states
|
||||||
|
.filter((job): job is UpgradeJobState => Boolean(job))
|
||||||
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeState(jobDir: string, state: UpgradeJobState): Promise<void> {
|
||||||
|
await fs.mkdir(jobDir, { recursive: true, mode: 0o700 });
|
||||||
|
await fs.writeFile(path.join(jobDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedArchiveName(name: string): boolean {
|
||||||
|
return name.endsWith('.tar') || name.endsWith('.tar.gz') || name.endsWith('.tgz');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(name: string): string {
|
||||||
|
const baseName = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
return baseName || 'upgrade-package.tar.gz';
|
||||||
|
}
|
||||||
60
src/app/api/canvas/projects/[id]/route.ts
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { deleteCanvasProject, getCanvasProject, updateCanvasProject } from '@/lib/canvas-store';
|
||||||
|
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||||
|
import { normalizeCanvasState } from '@/lib/canvas-store';
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const project = await getCanvasProject(userId, id);
|
||||||
|
if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ project });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects/:id] GET error:', error);
|
||||||
|
return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const project = await updateCanvasProject(userId, id, {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
state: body.state ? normalizeCanvasState(body.state) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||||
|
return NextResponse.json({ project });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects/:id] PUT error:', error);
|
||||||
|
return NextResponse.json({ error: '保存画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const deleted = await deleteCanvasProject(userId, id);
|
||||||
|
if (!deleted) return NextResponse.json({ error: '画布不存在' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects/:id] DELETE error:', error);
|
||||||
|
return NextResponse.json({ error: '删除画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/canvas/projects/route.ts
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createCanvasProject, listCanvasProjects } from '@/lib/canvas-store';
|
||||||
|
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const projects = await listCanvasProjects(userId);
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects] GET error:', error);
|
||||||
|
return NextResponse.json({ error: '读取画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = await getAuthenticatedUserId(request);
|
||||||
|
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const title = typeof body.title === 'string' ? body.title : '未命名画布';
|
||||||
|
const project = await createCanvasProject(userId, title);
|
||||||
|
return NextResponse.json({ project }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[canvas/projects] POST error:', error);
|
||||||
|
return NextResponse.json({ error: '创建画布项目失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ interface CustomApiConfig {
|
|||||||
systemApiId?: string;
|
systemApiId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENERATION_TIMEOUT = 300_000;
|
const GENERATION_TIMEOUT = Number(process.env.IMAGE_GENERATION_TIMEOUT_MS || 900_000);
|
||||||
const GENERATION_TIMEOUT_SECONDS = GENERATION_TIMEOUT / 1000;
|
const GENERATION_TIMEOUT_SECONDS = GENERATION_TIMEOUT / 1000;
|
||||||
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
|
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ async function fetchCustomImageGeneration(
|
|||||||
endpoint,
|
endpoint,
|
||||||
{ method: 'POST', headers: buildCustomApiHeaders(apiKey), body: JSON.stringify(requestBody) },
|
{ method: 'POST', headers: buildCustomApiHeaders(apiKey), body: JSON.stringify(requestBody) },
|
||||||
GENERATION_TIMEOUT,
|
GENERATION_TIMEOUT,
|
||||||
1,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -396,6 +396,30 @@ function objectKeysFromUnknown(value: unknown): string[] {
|
|||||||
*/
|
*/
|
||||||
function extractImagesFromGenerationsResponse(data: Record<string, unknown>): string[] {
|
function extractImagesFromGenerationsResponse(data: Record<string, unknown>): string[] {
|
||||||
const images: string[] = [];
|
const images: string[] = [];
|
||||||
|
const visit = (value: unknown, depth = 0) => {
|
||||||
|
if (depth > 6 || !value) return;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (value.startsWith('data:image/') || /^https?:\/\/[^\s"'<>]+/i.test(value)) images.push(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) visit(item, depth + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object') return;
|
||||||
|
|
||||||
|
const object = value as Record<string, unknown>;
|
||||||
|
if (typeof object.b64_json === 'string') images.push(`data:image/png;base64,${object.b64_json}`);
|
||||||
|
if (typeof object.url === 'string') visit(object.url, depth + 1);
|
||||||
|
if (typeof object.image_url === 'string') visit(object.image_url, depth + 1);
|
||||||
|
if (typeof object.image === 'string') visit(object.image, depth + 1);
|
||||||
|
if (typeof object.output === 'string') visit(object.output, depth + 1);
|
||||||
|
if (typeof object.result === 'string') visit(object.result, depth + 1);
|
||||||
|
for (const key of ['data', 'images', 'image_urls', 'output', 'result', 'results', 'message', 'content']) {
|
||||||
|
if (key in object) visit(object[key], depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (Array.isArray(data.data)) {
|
if (Array.isArray(data.data)) {
|
||||||
for (const item of data.data as Array<Record<string, unknown>>) {
|
for (const item of data.data as Array<Record<string, unknown>>) {
|
||||||
if (typeof item === 'string') { images.push(item); continue; }
|
if (typeof item === 'string') { images.push(item); continue; }
|
||||||
@@ -409,7 +433,17 @@ function extractImagesFromGenerationsResponse(data: Record<string, unknown>): st
|
|||||||
} else if (typeof data.image_url === 'string') {
|
} else if (typeof data.image_url === 'string') {
|
||||||
images.push(data.image_url);
|
images.push(data.image_url);
|
||||||
}
|
}
|
||||||
return images;
|
visit(data);
|
||||||
|
|
||||||
|
const streamEvents = data.__streamEvents;
|
||||||
|
if (Array.isArray(streamEvents)) {
|
||||||
|
for (const event of streamEvents) {
|
||||||
|
if (!event || typeof event !== 'object' || Array.isArray(event)) continue;
|
||||||
|
images.push(...extractImagesFromGenerationsResponse(event as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(images));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Track which strategy produced a result */
|
/** Track which strategy produced a result */
|
||||||
@@ -445,7 +479,7 @@ async function tryImageStrategy(
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
},
|
||||||
GENERATION_TIMEOUT,
|
GENERATION_TIMEOUT,
|
||||||
1,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -511,6 +545,7 @@ async function tryEditsWithFormData(
|
|||||||
const textFields: Record<string, string> = {
|
const textFields: Record<string, string> = {
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
stream: 'true',
|
||||||
};
|
};
|
||||||
if (size) textFields.size = size;
|
if (size) textFields.size = size;
|
||||||
if (count > 1) textFields.n = String(count);
|
if (count > 1) textFields.n = String(count);
|
||||||
@@ -547,7 +582,7 @@ async function tryEditsWithFormData(
|
|||||||
body: bodyBuffer,
|
body: bodyBuffer,
|
||||||
},
|
},
|
||||||
GENERATION_TIMEOUT,
|
GENERATION_TIMEOUT,
|
||||||
1,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -700,7 +735,7 @@ async function customApiImageToImage(
|
|||||||
const chatUrl = deriveChatCompletionsUrl(endpoint);
|
const chatUrl = deriveChatCompletionsUrl(endpoint);
|
||||||
const chatBody: Record<string, unknown> = {
|
const chatBody: Record<string, unknown> = {
|
||||||
model: customApiConfig.modelName,
|
model: customApiConfig.modelName,
|
||||||
stream: false,
|
stream: true,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -734,6 +769,7 @@ async function customApiImageToImage(
|
|||||||
n: count,
|
n: count,
|
||||||
size: size || '1024x1024',
|
size: size || '1024x1024',
|
||||||
response_format: 'b64_json',
|
response_format: 'b64_json',
|
||||||
|
stream: true,
|
||||||
init_image: rawBase64,
|
init_image: rawBase64,
|
||||||
denoising_strength: denoisingStrength,
|
denoising_strength: denoisingStrength,
|
||||||
};
|
};
|
||||||
@@ -871,6 +907,7 @@ export async function POST(request: NextRequest) {
|
|||||||
n,
|
n,
|
||||||
size: size || '1024x1024',
|
size: size || '1024x1024',
|
||||||
response_format: 'b64_json',
|
response_format: 'b64_json',
|
||||||
|
stream: true,
|
||||||
};
|
};
|
||||||
if (negativePrompt) {
|
if (negativePrompt) {
|
||||||
requestBody.negative_prompt = negativePrompt;
|
requestBody.negative_prompt = negativePrompt;
|
||||||
@@ -889,6 +926,7 @@ export async function POST(request: NextRequest) {
|
|||||||
'| size:', requestBody.size,
|
'| size:', requestBody.size,
|
||||||
'| n:', requestBody.n,
|
'| n:', requestBody.n,
|
||||||
'| aspect_ratio:', requestBody.aspect_ratio,
|
'| aspect_ratio:', requestBody.aspect_ratio,
|
||||||
|
'| stream:', requestBody.stream,
|
||||||
'| guidance_scale:', requestBody.guidance_scale,
|
'| guidance_scale:', requestBody.guidance_scale,
|
||||||
'| prompt_length:', prompt.length,
|
'| prompt_length:', prompt.length,
|
||||||
'| augmented_prompt_length:', augmentedPrompt.length);
|
'| augmented_prompt_length:', augmentedPrompt.length);
|
||||||
|
|||||||
5
src/app/canvas/page.tsx
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InfiniteCanvasWorkspace } from '@/components/canvas/infinite-canvas-workspace';
|
||||||
|
|
||||||
|
export default function CanvasPage() {
|
||||||
|
return <InfiniteCanvasWorkspace />;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import { useCustomApiKeys } from '@/lib/custom-api-store';
|
|||||||
import { useCreationHistory, type CreationRecord, isPlaceholder } from '@/lib/creation-history-store';
|
import { useCreationHistory, type CreationRecord, isPlaceholder } from '@/lib/creation-history-store';
|
||||||
import { useCreditRecords, formatRecordTime } from '@/lib/credit-records-store';
|
import { useCreditRecords, formatRecordTime } from '@/lib/credit-records-store';
|
||||||
import { useUserOrders, formatOrderTime } from '@/lib/order-store';
|
import { useUserOrders, formatOrderTime } from '@/lib/order-store';
|
||||||
import { useAuth } from '@/lib/auth-store';
|
import { readStoredAuth, useAuth } from '@/lib/auth-store';
|
||||||
import { useSiteConfig } from '@/lib/site-config';
|
import { useSiteConfig } from '@/lib/site-config';
|
||||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||||
import {
|
import {
|
||||||
@@ -232,7 +232,11 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAccountSave = async () => {
|
const handleAccountSave = async () => {
|
||||||
if (!user || !accessToken) {
|
const currentAuth = readStoredAuth();
|
||||||
|
const authUser = user || currentAuth.user;
|
||||||
|
const authToken = accessToken || currentAuth.accessToken;
|
||||||
|
|
||||||
|
if (!authUser || !authToken) {
|
||||||
setAccountMessage({ type: 'error', text: '请先登录后再修改资料' });
|
setAccountMessage({ type: 'error', text: '请先登录后再修改资料' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,7 +272,7 @@ export default function ProfilePage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${authToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
@@ -283,12 +287,12 @@ export default function ProfilePage() {
|
|||||||
email: data.profile.email,
|
email: data.profile.email,
|
||||||
nickname: data.profile.nickname,
|
nickname: data.profile.nickname,
|
||||||
phone: data.profile.phone || null,
|
phone: data.profile.phone || null,
|
||||||
membershipTier: data.profile.membership_tier || user.membershipTier,
|
membershipTier: data.profile.membership_tier || authUser.membershipTier,
|
||||||
creditsBalance: data.profile.credits_balance ?? user.creditsBalance,
|
creditsBalance: data.profile.credits_balance ?? authUser.creditsBalance,
|
||||||
dailyQuotaUsed: data.profile.daily_quota_used ?? user.dailyQuotaUsed,
|
dailyQuotaUsed: data.profile.daily_quota_used ?? authUser.dailyQuotaUsed,
|
||||||
dailyQuotaLimit: data.profile.daily_quota_limit ?? user.dailyQuotaLimit,
|
dailyQuotaLimit: data.profile.daily_quota_limit ?? authUser.dailyQuotaLimit,
|
||||||
avatarUrl: data.profile.avatar_url ?? user.avatarUrl,
|
avatarUrl: data.profile.avatar_url ?? authUser.avatarUrl,
|
||||||
createdAt: data.profile.created_at ?? user.createdAt,
|
createdAt: data.profile.created_at ?? authUser.createdAt,
|
||||||
emailVerified: data.profile.email_verified === true,
|
emailVerified: data.profile.email_verified === true,
|
||||||
emailVerifiedAt: data.profile.email_verified_at ?? null,
|
emailVerifiedAt: data.profile.email_verified_at ?? null,
|
||||||
});
|
});
|
||||||
@@ -305,7 +309,8 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSendProfileEmailCode = async () => {
|
const handleSendProfileEmailCode = async () => {
|
||||||
if (!accessToken) {
|
const authToken = accessToken || readStoredAuth().accessToken;
|
||||||
|
if (!authToken) {
|
||||||
setAccountMessage({ type: 'error', text: '请先登录后再验证邮箱' });
|
setAccountMessage({ type: 'error', text: '请先登录后再验证邮箱' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -320,7 +325,7 @@ export default function ProfilePage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${authToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ email: accountForm.email }),
|
body: JSON.stringify({ email: accountForm.email }),
|
||||||
});
|
});
|
||||||
@@ -337,7 +342,8 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifyProfileEmail = async () => {
|
const handleVerifyProfileEmail = async () => {
|
||||||
if (!accessToken) return;
|
const authToken = accessToken || readStoredAuth().accessToken;
|
||||||
|
if (!authToken) return;
|
||||||
if (!isEmail(accountForm.email) || !emailVerifyCode) {
|
if (!isEmail(accountForm.email) || !emailVerifyCode) {
|
||||||
setAccountMessage({ type: 'error', text: '请填写邮箱和验证码' });
|
setAccountMessage({ type: 'error', text: '请填写邮箱和验证码' });
|
||||||
return;
|
return;
|
||||||
@@ -348,7 +354,7 @@ export default function ProfilePage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${authToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ email: accountForm.email, code: emailVerifyCode }),
|
body: JSON.stringify({ email: accountForm.email, code: emailVerifyCode }),
|
||||||
});
|
});
|
||||||
|
|||||||
533
src/components/admin/system-upgrade-tab.tsx
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CheckCircle2,
|
||||||
|
FileArchive,
|
||||||
|
Flame,
|
||||||
|
History,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
ServerCog,
|
||||||
|
ShieldCheck,
|
||||||
|
UploadCloud,
|
||||||
|
XCircle,
|
||||||
|
SearchCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type UpgradeMode = 'hot' | 'cold';
|
||||||
|
type UpgradeStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'rolling_back'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'rolled_back'
|
||||||
|
| 'rollback_failed';
|
||||||
|
|
||||||
|
type UpgradeJob = {
|
||||||
|
id: string;
|
||||||
|
mode: UpgradeMode;
|
||||||
|
status: UpgradeStatus;
|
||||||
|
step: string;
|
||||||
|
message: string;
|
||||||
|
progress: number;
|
||||||
|
packageName: string;
|
||||||
|
packageHash?: string;
|
||||||
|
backupFile?: string;
|
||||||
|
sourceBackupFile?: string;
|
||||||
|
restartRequired?: boolean;
|
||||||
|
changedFiles?: string[];
|
||||||
|
preExistingFiles?: string[];
|
||||||
|
error?: string;
|
||||||
|
startedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
logs: string[];
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpgradeResponse = {
|
||||||
|
latest: UpgradeJob | null;
|
||||||
|
history: UpgradeJob[];
|
||||||
|
running: boolean;
|
||||||
|
stateDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RUNNING_STATUSES = new Set<UpgradeStatus>(['queued', 'running', 'rolling_back']);
|
||||||
|
const FINAL_STATUSES = new Set<UpgradeStatus>(['succeeded', 'failed', 'rolled_back', 'rollback_failed']);
|
||||||
|
|
||||||
|
function getAdminAuthHeaders(): HeadersInit {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('miaojing_auth');
|
||||||
|
if (!raw) return {};
|
||||||
|
const auth = JSON.parse(raw) as { accessToken?: string; session?: { access_token?: string } };
|
||||||
|
const token = auth.accessToken || auth.session?.access_token;
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemUpgradeTab() {
|
||||||
|
const [mode, setMode] = useState<UpgradeMode>('hot');
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [prechecking, setPrechecking] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [upgradeData, setUpgradeData] = useState<UpgradeResponse>({ latest: null, history: [], running: false, stateDir: '' });
|
||||||
|
const [currentLogJobIds, setCurrentLogJobIds] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedHistoryId, setExpandedHistoryId] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const latest = upgradeData.latest;
|
||||||
|
const latestIsRunning = latest ? RUNNING_STATUSES.has(latest.status) : false;
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/upgrade', {
|
||||||
|
headers: getAdminAuthHeaders(),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || '读取升级状态失败');
|
||||||
|
const latestJob = data.latest || null;
|
||||||
|
if (latestJob && RUNNING_STATUSES.has(latestJob.status)) {
|
||||||
|
setCurrentLogJobIds(previous => new Set(previous).add(latestJob.id));
|
||||||
|
}
|
||||||
|
setUpgradeData({
|
||||||
|
latest: latestJob,
|
||||||
|
history: Array.isArray(data.history) ? data.history : [],
|
||||||
|
running: data.running === true,
|
||||||
|
stateDir: data.stateDir || '',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) toast.error(error instanceof Error ? error.message : '读取升级状态失败');
|
||||||
|
} finally {
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, [loadStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!latestIsRunning) return;
|
||||||
|
const timer = window.setInterval(() => loadStatus({ silent: true }), 2500);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [latestIsRunning, loadStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!latest || !FINAL_STATUSES.has(latest.status)) return;
|
||||||
|
const timer = window.setTimeout(() => loadStatus({ silent: true }), 1200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [latest?.id, latest?.status, latest, loadStatus]);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(
|
||||||
|
() => Boolean(selectedFile) && !submitting && !prechecking && !upgradeData.running,
|
||||||
|
[selectedFile, submitting, prechecking, upgradeData.running],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit(dryRun = false) {
|
||||||
|
if (!selectedFile) {
|
||||||
|
toast.error('请选择升级包');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/\.(tar|tgz|tar\.gz)$/i.test(selectedFile.name)) {
|
||||||
|
toast.error('仅支持 .tar、.tar.gz、.tgz 升级包');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
setPrechecking(true);
|
||||||
|
} else {
|
||||||
|
setSubmitting(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set('mode', mode);
|
||||||
|
form.set('package', selectedFile);
|
||||||
|
if (dryRun) form.set('dryRun', 'true');
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/upgrade', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAdminAuthHeaders(),
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || '创建升级任务失败');
|
||||||
|
|
||||||
|
if (data.job?.id) {
|
||||||
|
setCurrentLogJobIds(previous => new Set(previous).add(data.job.id));
|
||||||
|
}
|
||||||
|
toast.success(dryRun ? '升级包预检已启动' : mode === 'hot' ? '热更新任务已启动' : '冷更新任务已启动');
|
||||||
|
if (!dryRun) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
await loadStatus({ silent: true });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : dryRun ? '启动预检失败' : '创建升级任务失败');
|
||||||
|
} finally {
|
||||||
|
if (dryRun) {
|
||||||
|
setPrechecking(false);
|
||||||
|
} else {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-amber-600" />
|
||||||
|
<AlertTitle>升级保护策略</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
每次升级都会先创建数据库、存储、环境配置备份和源码快照;任务失败会自动回滚到升级开始前的源码与数据状态。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(21rem,0.9fr)]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<UploadCloud className="h-5 w-5 text-primary" />
|
||||||
|
上传升级包
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>支持 tar、tar.gz、tgz 格式;热更新不重启平台,冷更新会构建并重启平台进程。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ModeCard
|
||||||
|
active={mode === 'hot'}
|
||||||
|
icon={<Flame className="h-5 w-5" />}
|
||||||
|
title="热更新"
|
||||||
|
description="只允许 public 静态资源等不影响运行时代码的补丁,应用后不重启。"
|
||||||
|
onClick={() => setMode('hot')}
|
||||||
|
/>
|
||||||
|
<ModeCard
|
||||||
|
active={mode === 'cold'}
|
||||||
|
icon={<ServerCog className="h-5 w-5" />}
|
||||||
|
title="冷更新"
|
||||||
|
description="适合代码、依赖、脚本等较大变更,会校验、构建、重启并健康检查。"
|
||||||
|
onClick={() => setMode('cold')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="upgrade-package">升级包</Label>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id="upgrade-package"
|
||||||
|
type="file"
|
||||||
|
accept=".tar,.tgz,.tar.gz,application/gzip,application/x-tar"
|
||||||
|
disabled={submitting || prechecking || upgradeData.running}
|
||||||
|
onChange={event => setSelectedFile(event.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
热更新包如包含 src、package.json、脚本或锁文件会被拒绝,请改用冷更新。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFile && (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<FileArchive className="h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<span className="truncate">{selectedFile.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(selectedFile.size)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border bg-background/50 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
升级前确认
|
||||||
|
</div>
|
||||||
|
<ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
|
||||||
|
<li>升级包不能包含 .env、node_modules、.git、backups、local-storage 等敏感目录。</li>
|
||||||
|
<li>失败回滚会恢复源码快照、数据库备份、存储目录和环境配置。</li>
|
||||||
|
<li>冷更新完成后会自动重启平台进程,并以 /api/health 作为成功判定。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button variant="outline" onClick={() => handleSubmit(true)} disabled={!canSubmit} className="gap-2">
|
||||||
|
{prechecking ? <Loader2 className="h-4 w-4 animate-spin" /> : <SearchCheck className="h-4 w-4" />}
|
||||||
|
{prechecking ? '正在预检...' : '先预检升级包'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleSubmit(false)} disabled={!canSubmit} className="gap-2">
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
|
||||||
|
{submitting ? '正在上传...' : mode === 'hot' ? '启动热更新' : '启动冷更新'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between gap-3 text-lg">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ServerCog className="h-5 w-5 text-primary" />
|
||||||
|
当前状态
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => loadStatus()} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>冷更新重启后也会从磁盘续上这里的任务状态。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-44 items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
正在加载升级状态
|
||||||
|
</div>
|
||||||
|
) : latest ? (
|
||||||
|
<UpgradeStatusPanel
|
||||||
|
job={latest}
|
||||||
|
showLogs={latestIsRunning || currentLogJobIds.has(latest.id)}
|
||||||
|
logTitle={latestIsRunning ? '实时升级日志' : '本次升级日志'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-44 flex-col items-center justify-center rounded-md border border-dashed border-border text-center">
|
||||||
|
<History className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
|
<div className="text-sm font-medium">暂无升级记录</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">上传升级包后会在这里显示进度和回滚结果</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<History className="h-5 w-5 text-primary" />
|
||||||
|
升级历史
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>保留全部升级任务,便于随时查看升级内容、执行日志、失败原因与回滚记录。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{upgradeData.history.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">暂无历史记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{upgradeData.history.map(job => (
|
||||||
|
<div key={job.id} className="rounded-md border border-border p-3 text-sm">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[9rem_1fr_auto] md:items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={job.status} />
|
||||||
|
<Badge variant="secondary">{job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{job.packageName}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{job.message}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 md:justify-end">
|
||||||
|
<div className="text-xs text-muted-foreground md:text-right">{formatDate(job.updatedAt)}</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExpandedHistoryId(expandedHistoryId === job.id ? null : job.id)}
|
||||||
|
>
|
||||||
|
{expandedHistoryId === job.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
{expandedHistoryId === job.id ? '收起详情' : '查看详情'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedHistoryId === job.id && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<UpgradeStatusPanel job={job} showLogs logTitle="历史升级日志" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeCard({
|
||||||
|
active,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'min-h-32 rounded-md border p-4 text-left transition-colors',
|
||||||
|
active ? 'border-primary bg-primary/10 text-foreground' : 'border-border bg-background hover:bg-muted/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2 font-semibold">
|
||||||
|
<span className={cn('flex h-9 w-9 items-center justify-center rounded-md', active ? 'bg-primary text-zinc-950' : 'bg-muted text-muted-foreground')}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpgradeStatusPanel({
|
||||||
|
job,
|
||||||
|
showLogs,
|
||||||
|
logTitle = '执行日志',
|
||||||
|
}: {
|
||||||
|
job: UpgradeJob;
|
||||||
|
showLogs: boolean;
|
||||||
|
logTitle?: string;
|
||||||
|
}) {
|
||||||
|
const changedFiles = job.changedFiles || [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={job.status} />
|
||||||
|
<Badge variant="secondary">{job.dryRun ? '预检' : job.mode === 'hot' ? '热更新' : '冷更新'}</Badge>
|
||||||
|
{job.dryRun && <Badge className="bg-sky-500/15 text-sky-600 hover:bg-sky-500/15">未覆盖文件</Badge>}
|
||||||
|
<Badge className={statusBadgeClass(job.status)}>{statusLabel(job.status)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{formatDate(job.updatedAt)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="font-medium">{job.message}</span>
|
||||||
|
<span className="text-muted-foreground">{Math.max(0, Math.min(100, job.progress || 0))}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={Math.max(0, Math.min(100, job.progress || 0))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 text-sm">
|
||||||
|
<InfoRow label="任务 ID" value={job.id} />
|
||||||
|
<InfoRow label="升级包" value={job.packageName} />
|
||||||
|
<InfoRow label="当前步骤" value={job.step} />
|
||||||
|
<InfoRow label="文件数量" value={`${changedFiles.length} 个文件`} />
|
||||||
|
<InfoRow label="需要重启" value={job.restartRequired ? '是' : '否'} />
|
||||||
|
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}
|
||||||
|
{job.sourceBackupFile && <InfoRow label="源码快照" value={job.sourceBackupFile} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changedFiles.length > 0 && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/25 p-3">
|
||||||
|
<div className="mb-2 text-sm font-medium">升级内容</div>
|
||||||
|
<div className="max-h-40 overflow-auto rounded bg-background/70 p-2 font-mono text-xs leading-5 text-muted-foreground">
|
||||||
|
{changedFiles.map(file => (
|
||||||
|
<div key={file} className="truncate" title={file}>{file}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.error && (
|
||||||
|
<Alert variant={job.status === 'rollback_failed' ? 'destructive' : 'default'} className="border-amber-500/30 bg-amber-500/5">
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
<AlertTitle>{job.status === 'rolled_back' ? '已自动回滚' : '升级错误'}</AlertTitle>
|
||||||
|
<AlertDescription>{job.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLogs && job.logs?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="font-medium">{logTitle}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{job.logs.length} 行</span>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-72 overflow-auto rounded-md bg-zinc-950 p-3 text-xs leading-5 text-zinc-100">
|
||||||
|
{job.logs.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[5rem_1fr] gap-3 rounded-md bg-muted/35 px-3 py-2">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="truncate font-mono text-xs" title={value}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: UpgradeStatus }) {
|
||||||
|
if (status === 'succeeded') return <CheckCircle2 className="h-4 w-4 text-emerald-500" />;
|
||||||
|
if (status === 'rolled_back') return <RotateCcw className="h-4 w-4 text-amber-500" />;
|
||||||
|
if (status === 'failed' || status === 'rollback_failed') return <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
|
return <Loader2 className="h-4 w-4 animate-spin text-primary" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: UpgradeStatus): string {
|
||||||
|
const labels: Record<UpgradeStatus, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
running: '执行中',
|
||||||
|
rolling_back: '回滚中',
|
||||||
|
succeeded: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
rolled_back: '已回滚',
|
||||||
|
rollback_failed: '回滚失败',
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: UpgradeStatus): string {
|
||||||
|
if (status === 'succeeded') return 'bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/15';
|
||||||
|
if (status === 'rolled_back') return 'bg-amber-500/15 text-amber-600 hover:bg-amber-500/15';
|
||||||
|
if (status === 'failed' || status === 'rollback_failed') return 'bg-destructive/15 text-destructive hover:bg-destructive/15';
|
||||||
|
return 'bg-primary/15 text-primary hover:bg-primary/15';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ['KB', 'MB', 'GB'];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
2137
src/components/canvas/infinite-canvas-workspace.tsx
Executable file
553
src/components/canvas/react-flow-canvas.tsx
Executable file
@@ -0,0 +1,553 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Background,
|
||||||
|
ConnectionLineType,
|
||||||
|
Controls,
|
||||||
|
Handle,
|
||||||
|
MarkerType,
|
||||||
|
MiniMap,
|
||||||
|
Position,
|
||||||
|
ReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
useReactFlow,
|
||||||
|
type Connection,
|
||||||
|
type Edge,
|
||||||
|
type EdgeChange,
|
||||||
|
type Node as FlowNode,
|
||||||
|
type NodeChange,
|
||||||
|
type NodeProps,
|
||||||
|
type Rect,
|
||||||
|
type ReactFlowInstance,
|
||||||
|
type Viewport as FlowViewport,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import { Brush, FileImage, Image as ImageIcon, Layers, Link2, Loader2, Maximize2, Move, Type } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { CanvasConnection, CanvasLayer, CanvasNode, CanvasViewport } from '@/lib/canvas-types';
|
||||||
|
|
||||||
|
type NodePositionCommit = { id: string; x: number; y: number };
|
||||||
|
|
||||||
|
type CanvasFlowNodeData = {
|
||||||
|
node: CanvasNode;
|
||||||
|
selected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
allNodes: CanvasNode[];
|
||||||
|
layerCanvasSize: number;
|
||||||
|
layerColors: Record<CanvasLayer['type'], string>;
|
||||||
|
onSelect: (id: string, additive: boolean) => void;
|
||||||
|
onStartConnect: (id: string) => void;
|
||||||
|
onRemoveConnection: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
|
||||||
|
|
||||||
|
type ReactFlowCanvasProps = {
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
selectedNodeIds: string[];
|
||||||
|
connectingFromId: string | null;
|
||||||
|
editable: boolean;
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom: number;
|
||||||
|
layerCanvasSize: number;
|
||||||
|
layerColors: Record<CanvasLayer['type'], string>;
|
||||||
|
onSelectNode: (id: string, additive: boolean) => void;
|
||||||
|
onSelectionChange: (ids: string[]) => void;
|
||||||
|
onStartConnect: (id: string) => void;
|
||||||
|
onConnect: (sourceId: string, targetId: string) => void;
|
||||||
|
onRemoveConnection: (id: string) => void;
|
||||||
|
onRemoveConnections: (ids: string[]) => void;
|
||||||
|
onNodesCommit: (positions: NodePositionCommit[], options?: { history?: boolean; dirty?: boolean }) => void;
|
||||||
|
onViewportCommit: (viewport: CanvasViewport) => void;
|
||||||
|
onReady?: (controls: CanvasFlowControls | null) => void;
|
||||||
|
onPaneClick: (point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => void;
|
||||||
|
onPaneDoubleClick: (point: { x: number; y: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasFlowControls = {
|
||||||
|
setViewport: (viewport: CanvasViewport) => Promise<boolean>;
|
||||||
|
zoomTo: (zoom: number) => Promise<boolean>;
|
||||||
|
fitBounds: (bounds: Rect, options?: { padding?: number; duration?: number }) => Promise<boolean>;
|
||||||
|
getViewport: () => CanvasViewport;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_COLORS = ['#22c55e', '#06b6d4', '#8b5cf6', '#f59e0b', '#ef4444', '#14b8a6'];
|
||||||
|
const SNAP_GRID: [number, number] = [20, 20];
|
||||||
|
const MULTI_SELECTION_KEYS = ['Meta', 'Control', 'Shift'];
|
||||||
|
const FLOW_FIT_VIEW_OPTIONS = { padding: 0.2 };
|
||||||
|
const CANVAS_NODE_TYPES = { canvasNode: CanvasNodeCard };
|
||||||
|
|
||||||
|
function getNodeImageUrl(node?: CanvasNode | null) {
|
||||||
|
if (!node) return '';
|
||||||
|
if (node.type === 'image') return node.imageUrl || '';
|
||||||
|
return node.selectedOutput || node.outputImages?.[0] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||||
|
const { node, selected, connecting, connections, allNodes, layerCanvasSize, layerColors, onSelect, onStartConnect, onRemoveConnection } = data;
|
||||||
|
const incomingImage = allNodes.find(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id && !!getNodeImageUrl(item)));
|
||||||
|
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-canvas-node
|
||||||
|
data-node-id={node.id}
|
||||||
|
className={cn(
|
||||||
|
'group h-full overflow-visible rounded-lg border transition-shadow',
|
||||||
|
node.type === 'frame' ? 'bg-background/20 shadow-none' : 'bg-card shadow-sm',
|
||||||
|
selected ? 'border-primary ring-2 ring-primary/20' : connecting ? 'border-emerald-500 ring-2 ring-emerald-500/20' : 'border-border',
|
||||||
|
)}
|
||||||
|
style={{ borderColor: node.type === 'frame' && !selected ? node.color || '#22c55e' : undefined }}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('button,input,textarea,[role="combobox"],.nodrag')) return;
|
||||||
|
onSelect(node.id, event.ctrlKey || event.metaKey || event.shiftKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Left} id="input" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||||
|
<Handle type="source" position={Position.Right} id="output" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'nodrag absolute right-0 top-1/2 z-20 flex h-7 w-7 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100',
|
||||||
|
connecting ? 'border-emerald-500 bg-emerald-500 text-white opacity-100' : '',
|
||||||
|
)}
|
||||||
|
title="从此模块开始连线"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onStartConnect(node.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-full overflow-hidden rounded-lg">
|
||||||
|
<div className="flex h-10 items-center justify-between border-b bg-muted/40 px-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||||
|
{node.type === 'text' ? <Type className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'image' ? <ImageIcon className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'text2img' ? <Brush className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'img2img' ? <FileImage className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'layeredImage' ? <Layers className="h-4 w-4" /> : null}
|
||||||
|
{node.type === 'frame' ? <Maximize2 className="h-4 w-4" /> : null}
|
||||||
|
<span className="truncate">{node.title}</span>
|
||||||
|
</div>
|
||||||
|
{node.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin text-primary" /> : <Move className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[calc(100%-2.5rem)] p-3">
|
||||||
|
{node.type === 'text' ? (
|
||||||
|
<div className="h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-sm leading-6">{node.text}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'image' ? (
|
||||||
|
<div className="h-full overflow-hidden rounded-md bg-muted">
|
||||||
|
{node.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={node.imageUrl} alt={node.title} className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">未选择图片</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'text2img' || node.type === 'img2img' ? (
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<div className="line-clamp-3 min-h-14 rounded-md bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||||
|
{node.prompt || (connections.some(connection => connection.targetNodeId === node.id) ? '已连接上游内容,可继续补充描述' : '在右侧输入创作描述')}
|
||||||
|
</div>
|
||||||
|
{node.type === 'img2img' ? (
|
||||||
|
<div className="h-20 overflow-hidden rounded-md border bg-muted">
|
||||||
|
{node.referenceImage || getNodeImageUrl(incomingImage) ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={node.referenceImage || getNodeImageUrl(incomingImage)} alt="参考图" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">从右侧选择参考图或连接图片节点</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
|
||||||
|
{node.selectedOutput ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={node.selectedOutput} alt="生成结果" className="h-full w-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : '等待生成'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'layeredImage' ? (
|
||||||
|
<div className="grid h-full grid-cols-[1fr_120px] gap-3 rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="relative overflow-hidden rounded-md border bg-background">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted))_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted))_75%)] bg-[length:24px_24px] bg-[position:0_0,0_12px,12px_-12px,-12px_0px] opacity-35" />
|
||||||
|
{(node.layers || []).filter(layer => layer.visible).map(layer => (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
className={cn(
|
||||||
|
'absolute border border-white/60 shadow-sm',
|
||||||
|
layer.type === 'text' ? 'flex items-center px-2 text-xs font-semibold' : 'rounded-md',
|
||||||
|
layer.locked ? 'outline outline-1 outline-dashed outline-muted-foreground/70' : '',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${(layer.x / layerCanvasSize) * 100}%`,
|
||||||
|
top: `${(layer.y / layerCanvasSize) * 100}%`,
|
||||||
|
width: `${(layer.width / layerCanvasSize) * 100}%`,
|
||||||
|
height: `${(layer.height / layerCanvasSize) * 100}%`,
|
||||||
|
backgroundColor: layer.type === 'text' ? 'transparent' : layer.color || layerColors[layer.type],
|
||||||
|
color: layer.color || layerColors.text,
|
||||||
|
opacity: layer.opacity ?? 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer.type === 'text' ? layer.text || layer.name : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-1 overflow-y-auto">
|
||||||
|
{(node.layers || []).slice().reverse().map(layer => (
|
||||||
|
<div key={layer.id} className="truncate rounded-md border bg-background px-2 py-1 text-[11px]">
|
||||||
|
<span className={layer.visible ? '' : 'text-muted-foreground line-through'}>{layer.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{node.type === 'frame' ? (
|
||||||
|
<div className="flex h-full flex-col justify-end rounded-md border border-dashed bg-background/25 p-3 text-xs text-muted-foreground" style={{ borderColor: node.color || '#22c55e' }}>
|
||||||
|
<div className="rounded-md bg-background/80 px-3 py-2 shadow-sm">{node.text || '流程分组'}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && nodeConnections.length > 0 ? (
|
||||||
|
<div className="nodrag absolute left-3 top-full z-30 mt-2 w-64 rounded-lg border bg-background/95 p-2 text-xs shadow-lg backdrop-blur">
|
||||||
|
<div className="mb-1 font-medium text-muted-foreground">相关连线</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nodeConnections.slice(0, 4).map(connection => {
|
||||||
|
const source = allNodes.find(item => item.id === connection.sourceNodeId);
|
||||||
|
const target = allNodes.find(item => item.id === connection.targetNodeId);
|
||||||
|
return (
|
||||||
|
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border px-2 py-1">
|
||||||
|
<span className="min-w-0 truncate">{source?.title || '未知模块'} → {target?.title || '未知模块'}</span>
|
||||||
|
<button className="text-destructive hover:underline" onClick={() => onRemoveConnection(connection.id)}>删</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFlowNodePositions(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const bMap = new Map(b.map(node => [node.id, node]));
|
||||||
|
return a.every(node => {
|
||||||
|
const next = bMap.get(node.id);
|
||||||
|
return !!next && node.position.x === next.position.x && node.position.y === next.position.y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const bMap = new Map(b.map(node => [node.id, node]));
|
||||||
|
return a.every(node => {
|
||||||
|
const next = bMap.get(node.id);
|
||||||
|
return !!next
|
||||||
|
&& node.position.x === next.position.x
|
||||||
|
&& node.position.y === next.position.y
|
||||||
|
&& node.selected === next.selected
|
||||||
|
&& node.draggable === next.draggable
|
||||||
|
&& node.selectable === next.selectable
|
||||||
|
&& node.width === next.width
|
||||||
|
&& node.height === next.height
|
||||||
|
&& node.data.node === next.data.node
|
||||||
|
&& node.data.selected === next.data.selected
|
||||||
|
&& node.data.connecting === next.data.connecting
|
||||||
|
&& node.data.connections === next.data.connections
|
||||||
|
&& node.data.allNodes === next.data.allNodes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number): CanvasViewport {
|
||||||
|
return {
|
||||||
|
x: viewport.x,
|
||||||
|
y: viewport.y,
|
||||||
|
zoom: Math.min(maxZoom, Math.max(minZoom, viewport.zoom)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasInner({
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
viewport,
|
||||||
|
selectedNodeIds,
|
||||||
|
connectingFromId,
|
||||||
|
editable,
|
||||||
|
minZoom,
|
||||||
|
maxZoom,
|
||||||
|
layerCanvasSize,
|
||||||
|
layerColors,
|
||||||
|
onSelectNode,
|
||||||
|
onSelectionChange,
|
||||||
|
onStartConnect,
|
||||||
|
onConnect,
|
||||||
|
onRemoveConnection,
|
||||||
|
onRemoveConnections,
|
||||||
|
onNodesCommit,
|
||||||
|
onViewportCommit,
|
||||||
|
onReady,
|
||||||
|
onPaneClick,
|
||||||
|
onPaneDoubleClick,
|
||||||
|
}: ReactFlowCanvasProps) {
|
||||||
|
const reactFlow = useReactFlow<CanvasFlowNode, Edge>();
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
const movingRef = useRef(false);
|
||||||
|
const paneClickTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const incomingNodes = useMemo<CanvasFlowNode[]>(() => nodes.map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
type: 'canvasNode',
|
||||||
|
position: { x: node.x, y: node.y },
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
selected: selectedNodeIds.includes(node.id),
|
||||||
|
draggable: editable,
|
||||||
|
selectable: editable,
|
||||||
|
zIndex: node.type === 'frame' ? Math.min(node.zIndex, 0) : node.zIndex,
|
||||||
|
data: {
|
||||||
|
node,
|
||||||
|
selected: selectedNodeIds.includes(node.id),
|
||||||
|
connecting: connectingFromId === node.id,
|
||||||
|
connections,
|
||||||
|
allNodes: nodes,
|
||||||
|
layerCanvasSize,
|
||||||
|
layerColors,
|
||||||
|
onSelect: onSelectNode,
|
||||||
|
onStartConnect,
|
||||||
|
onRemoveConnection,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
},
|
||||||
|
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, nodes, onRemoveConnection, onSelectNode, onStartConnect, selectedNodeIds]);
|
||||||
|
|
||||||
|
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);
|
||||||
|
|
||||||
|
const flowEdges = useMemo<Edge[]>(() => connections.map((connection, index) => ({
|
||||||
|
id: connection.id,
|
||||||
|
source: connection.sourceNodeId,
|
||||||
|
target: connection.targetNodeId,
|
||||||
|
sourceHandle: 'output',
|
||||||
|
targetHandle: 'input',
|
||||||
|
label: connection.label,
|
||||||
|
type: 'smoothstep',
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed },
|
||||||
|
style: {
|
||||||
|
stroke: CONNECTION_COLORS[index % CONNECTION_COLORS.length],
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
},
|
||||||
|
labelBgStyle: { fill: 'hsl(var(--background))', fillOpacity: 0.9 },
|
||||||
|
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 12 },
|
||||||
|
})), [connections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFlowNodes(current => {
|
||||||
|
if (draggingRef.current && sameFlowNodePositions(current, incomingNodes)) return current;
|
||||||
|
if (sameFlowNodes(current, incomingNodes)) return current;
|
||||||
|
return incomingNodes;
|
||||||
|
});
|
||||||
|
}, [incomingNodes]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (paneClickTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(paneClickTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
onReady?.(null);
|
||||||
|
}, [onReady]);
|
||||||
|
|
||||||
|
const commitCurrentNodePositions = useCallback((options?: { history?: boolean; dirty?: boolean }) => {
|
||||||
|
const positions = reactFlow.getNodes().map(node => ({
|
||||||
|
id: node.id,
|
||||||
|
x: node.position.x,
|
||||||
|
y: node.position.y,
|
||||||
|
}));
|
||||||
|
onNodesCommit(positions, options);
|
||||||
|
}, [onNodesCommit, reactFlow]);
|
||||||
|
|
||||||
|
const handleNodesChange = useCallback((changes: NodeChange<CanvasFlowNode>[]) => {
|
||||||
|
const relevantChanges = changes.filter(change => change.type === 'position' || change.type === 'select' || change.type === 'remove');
|
||||||
|
if (relevantChanges.length === 0) return;
|
||||||
|
|
||||||
|
setFlowNodes(current => {
|
||||||
|
const removedIds = new Set(
|
||||||
|
relevantChanges
|
||||||
|
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'remove' }> => change.type === 'remove')
|
||||||
|
.map(change => change.id),
|
||||||
|
);
|
||||||
|
let changed = removedIds.size > 0;
|
||||||
|
const nextNodes = current.map(node => {
|
||||||
|
if (removedIds.has(node.id)) return node;
|
||||||
|
const positionChange = relevantChanges.find(
|
||||||
|
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'position' }> => change.type === 'position' && change.id === node.id && !!change.position,
|
||||||
|
);
|
||||||
|
const selectChange = relevantChanges.find(
|
||||||
|
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.id === node.id,
|
||||||
|
);
|
||||||
|
const nextPosition = positionChange?.position || node.position;
|
||||||
|
const nextSelected = typeof selectChange?.selected === 'boolean' ? selectChange.selected : node.selected;
|
||||||
|
if (nextPosition === node.position && nextSelected === node.selected) return node;
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: nextPosition,
|
||||||
|
selected: nextSelected,
|
||||||
|
};
|
||||||
|
}).filter(node => !removedIds.has(node.id));
|
||||||
|
return changed ? nextNodes : current;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = relevantChanges
|
||||||
|
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.selected)
|
||||||
|
.map(change => change.id);
|
||||||
|
if (selected.length > 0) onSelectionChange(selected);
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
const handleEdgesChange = useCallback((changes: EdgeChange<Edge>[]) => {
|
||||||
|
const removedIds = changes.filter(change => change.type === 'remove').map(change => change.id);
|
||||||
|
if (removedIds.length > 0) onRemoveConnections(removedIds);
|
||||||
|
}, [onRemoveConnections]);
|
||||||
|
|
||||||
|
const handleConnect = useCallback((connection: Connection) => {
|
||||||
|
if (!connection.source || !connection.target) return;
|
||||||
|
onConnect(connection.source, connection.target);
|
||||||
|
}, [onConnect]);
|
||||||
|
|
||||||
|
const handleMoveEnd = useCallback((_event: MouseEvent | TouchEvent | null, nextViewport: FlowViewport) => {
|
||||||
|
if (!movingRef.current) return;
|
||||||
|
movingRef.current = false;
|
||||||
|
const committedViewport = clampViewport(nextViewport, minZoom, maxZoom);
|
||||||
|
onViewportCommit(committedViewport);
|
||||||
|
}, [maxZoom, minZoom, onViewportCommit]);
|
||||||
|
|
||||||
|
const handleNodeDragStart = useCallback(() => {
|
||||||
|
draggingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeDragStop = useCallback(() => {
|
||||||
|
draggingRef.current = false;
|
||||||
|
commitCurrentNodePositions({ history: true, dirty: true });
|
||||||
|
}, [commitCurrentNodePositions]);
|
||||||
|
|
||||||
|
const handleMoveStart = useCallback(() => {
|
||||||
|
movingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaneReactFlowClick = useCallback((event: React.MouseEvent) => {
|
||||||
|
const point = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||||
|
if (event.detail >= 2) {
|
||||||
|
if (paneClickTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(paneClickTimerRef.current);
|
||||||
|
paneClickTimerRef.current = null;
|
||||||
|
}
|
||||||
|
onPaneDoubleClick(reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY }, { snapToGrid: true, snapGrid: SNAP_GRID }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (paneClickTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(paneClickTimerRef.current);
|
||||||
|
}
|
||||||
|
paneClickTimerRef.current = window.setTimeout(() => {
|
||||||
|
paneClickTimerRef.current = null;
|
||||||
|
onPaneClick(point, event);
|
||||||
|
}, 180);
|
||||||
|
}, [onPaneClick, onPaneDoubleClick, reactFlow]);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: CanvasFlowNode[] }) => {
|
||||||
|
onSelectionChange(selectedNodes.map(node => node.id));
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
|
const getMiniMapNodeColor = useCallback((node: FlowNode) => (
|
||||||
|
selectedNodeIds.includes(node.id) ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'
|
||||||
|
), [selectedNodeIds]);
|
||||||
|
|
||||||
|
const handleInit = useCallback((instance: ReactFlowInstance<CanvasFlowNode, Edge>) => {
|
||||||
|
onReady?.({
|
||||||
|
setViewport: (nextViewport) => instance.setViewport(nextViewport, { duration: 120 }),
|
||||||
|
zoomTo: (zoom) => instance.zoomTo(Math.min(maxZoom, Math.max(minZoom, zoom)), { duration: 120 }),
|
||||||
|
fitBounds: (bounds, options) => instance.fitBounds(bounds, options),
|
||||||
|
getViewport: () => clampViewport(instance.getViewport(), minZoom, maxZoom),
|
||||||
|
});
|
||||||
|
}, [maxZoom, minZoom, onReady]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
className="canvas-flow"
|
||||||
|
nodes={flowNodes}
|
||||||
|
edges={flowEdges}
|
||||||
|
nodeTypes={CANVAS_NODE_TYPES}
|
||||||
|
minZoom={minZoom}
|
||||||
|
maxZoom={maxZoom}
|
||||||
|
snapToGrid
|
||||||
|
snapGrid={SNAP_GRID}
|
||||||
|
defaultViewport={viewport}
|
||||||
|
zoomOnScroll
|
||||||
|
zoomOnPinch
|
||||||
|
panOnScroll={false}
|
||||||
|
panOnDrag
|
||||||
|
selectionOnDrag={false}
|
||||||
|
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
|
||||||
|
deleteKeyCode={null}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
nodesDraggable={editable}
|
||||||
|
nodesConnectable={editable}
|
||||||
|
nodesFocusable={false}
|
||||||
|
edgesFocusable={false}
|
||||||
|
connectionLineType={ConnectionLineType.SmoothStep}
|
||||||
|
fitViewOptions={FLOW_FIT_VIEW_OPTIONS}
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
onConnect={handleConnect}
|
||||||
|
onNodeDragStart={handleNodeDragStart}
|
||||||
|
onNodeDragStop={handleNodeDragStop}
|
||||||
|
onMoveStart={handleMoveStart}
|
||||||
|
onMoveEnd={handleMoveEnd}
|
||||||
|
onPaneClick={handlePaneReactFlowClick}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
onInit={handleInit}
|
||||||
|
>
|
||||||
|
<Background gap={20} size={1} color="hsl(var(--border))" />
|
||||||
|
<Background gap={100} size={1.2} color="hsl(var(--muted-foreground))" />
|
||||||
|
<Controls showInteractive={false} position="bottom-left" />
|
||||||
|
<MiniMap
|
||||||
|
position="bottom-right"
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
nodeColor={getMiniMapNodeColor}
|
||||||
|
maskColor="hsl(var(--background) / 0.58)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReactFlowCanvas(props: ReactFlowCanvasProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<FlowCanvasInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ import { Sparkles, Loader2, Download, Upload, Wand2, Image as ImageIcon, History
|
|||||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||||
import { downloadFile } from '@/lib/utils';
|
import { downloadFile } from '@/lib/utils';
|
||||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||||
import { useSiteConfig } from '@/lib/site-config';
|
import { useSiteConfig } from '@/lib/site-config';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -355,7 +355,7 @@ export function ImageToImagePanel() {
|
|||||||
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
||||||
'image',
|
'image',
|
||||||
requestBody,
|
requestBody,
|
||||||
{ timeoutMs: 300_000, onStatus: setGenerationJobStatus },
|
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
|
||||||
);
|
);
|
||||||
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
||||||
if (data.images && data.images.length > 0) {
|
if (data.images && data.images.length > 0) {
|
||||||
@@ -387,7 +387,10 @@ export function ImageToImagePanel() {
|
|||||||
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
if (err instanceof GenerationJobStillRunningError) {
|
||||||
|
setGenerationError(null);
|
||||||
|
toast.info('生成任务仍在执行,可稍后在创作历史中查看');
|
||||||
|
} else if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
||||||
} else {
|
} else {
|
||||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { Sparkles, Loader2, Download, Wand2, Image as ImageIcon, History, Chevro
|
|||||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||||
import { downloadFile } from '@/lib/utils';
|
import { downloadFile } from '@/lib/utils';
|
||||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||||
import { useSiteConfig } from '@/lib/site-config';
|
import { useSiteConfig } from '@/lib/site-config';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -227,7 +227,7 @@ export function TextToImagePanel() {
|
|||||||
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
|
||||||
'image',
|
'image',
|
||||||
requestBody,
|
requestBody,
|
||||||
{ timeoutMs: 300_000, onStatus: setGenerationJobStatus },
|
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
|
||||||
);
|
);
|
||||||
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
|
||||||
if (data.images && data.images.length > 0) {
|
if (data.images && data.images.length > 0) {
|
||||||
@@ -258,7 +258,10 @@ export function TextToImagePanel() {
|
|||||||
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
setGenerationError(createGenerationError(data.error || '图片生成失败'));
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
if (err instanceof GenerationJobStillRunningError) {
|
||||||
|
setGenerationError(null);
|
||||||
|
toast.info('生成任务仍在执行,可稍后在创作历史中查看');
|
||||||
|
} else if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
|
||||||
} else {
|
} else {
|
||||||
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
LogOut,
|
LogOut,
|
||||||
Shield,
|
Shield,
|
||||||
|
PanelsTopLeft,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -26,6 +27,7 @@ const navItems = [
|
|||||||
{ href: '/', label: '首页', icon: Sparkles },
|
{ href: '/', label: '首页', icon: Sparkles },
|
||||||
{ href: '/create', label: '创作', icon: Brush },
|
{ href: '/create', label: '创作', icon: Brush },
|
||||||
{ href: '/gallery', label: '画廊', icon: LayoutGrid },
|
{ href: '/gallery', label: '画廊', icon: LayoutGrid },
|
||||||
|
{ href: '/canvas', label: '画布', icon: PanelsTopLeft },
|
||||||
{ href: '/profile', label: '我的', icon: User },
|
{ href: '/profile', label: '我的', icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -26,20 +26,36 @@ export interface AuthState {
|
|||||||
const STORAGE_KEY = 'miaojing_auth';
|
const STORAGE_KEY = 'miaojing_auth';
|
||||||
const EVENT_KEY = 'miaojing_auth_updated';
|
const EVENT_KEY = 'miaojing_auth_updated';
|
||||||
|
|
||||||
function getStoredAuth(): AuthState {
|
export function readStoredAuth(): AuthState {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return { user: null, accessToken: null, isLoggedIn: false };
|
return { user: null, accessToken: null, isLoggedIn: false };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return { user: null, accessToken: null, isLoggedIn: false };
|
if (!raw) return { user: null, accessToken: null, isLoggedIn: false };
|
||||||
const parsed = JSON.parse(raw) as AuthState;
|
const parsed = JSON.parse(raw) as Partial<AuthState> & { session?: { access_token?: unknown } };
|
||||||
return parsed;
|
const accessToken = typeof parsed.accessToken === 'string' && parsed.accessToken
|
||||||
|
? parsed.accessToken
|
||||||
|
: typeof parsed.session?.access_token === 'string'
|
||||||
|
? parsed.session.access_token
|
||||||
|
: null;
|
||||||
|
if (!parsed.user || !accessToken) {
|
||||||
|
return { user: null, accessToken: null, isLoggedIn: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: parsed.user,
|
||||||
|
accessToken,
|
||||||
|
isLoggedIn: true,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { user: null, accessToken: null, isLoggedIn: false };
|
return { user: null, accessToken: null, isLoggedIn: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoredAuth(): AuthState {
|
||||||
|
return readStoredAuth();
|
||||||
|
}
|
||||||
|
|
||||||
function setStoredAuth(state: AuthState): void {
|
function setStoredAuth(state: AuthState): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
|||||||
150
src/lib/canvas-store.ts
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
import { getDbClient } from '@/storage/database/local-db';
|
||||||
|
import { createEmptyCanvasState, type CanvasProject, type CanvasProjectState } from '@/lib/canvas-types';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCanvasState(value: unknown): CanvasProjectState {
|
||||||
|
if (!isRecord(value)) return createEmptyCanvasState();
|
||||||
|
const fallback = createEmptyCanvasState();
|
||||||
|
const viewport = isRecord(value.viewport) ? value.viewport : fallback.viewport;
|
||||||
|
return {
|
||||||
|
nodes: Array.isArray(value.nodes) ? value.nodes as CanvasProjectState['nodes'] : [],
|
||||||
|
connections: Array.isArray(value.connections) ? value.connections as CanvasProjectState['connections'] : [],
|
||||||
|
assets: Array.isArray(value.assets) ? value.assets as CanvasProjectState['assets'] : [],
|
||||||
|
viewport: {
|
||||||
|
x: typeof viewport.x === 'number' ? viewport.x : 0,
|
||||||
|
y: typeof viewport.y === 'number' ? viewport.y : 0,
|
||||||
|
zoom: typeof viewport.zoom === 'number' ? Math.min(3, Math.max(0.2, viewport.zoom)) : 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCanvasSchema() {
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS canvas_projects (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL DEFAULT '未命名画布',
|
||||||
|
state jsonb NOT NULL DEFAULT '{"nodes":[],"assets":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await client.query('CREATE INDEX IF NOT EXISTS idx_canvas_projects_user_updated ON canvas_projects(user_id, updated_at DESC)');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProject(row: Record<string, unknown>): CanvasProject {
|
||||||
|
return {
|
||||||
|
id: String(row.id || ''),
|
||||||
|
userId: String(row.user_id || ''),
|
||||||
|
title: String(row.title || '未命名画布'),
|
||||||
|
state: normalizeCanvasState(row.state),
|
||||||
|
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at || ''),
|
||||||
|
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCanvasProjects(userId: string): Promise<CanvasProject[]> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT id, user_id, title, state, created_at, updated_at
|
||||||
|
FROM canvas_projects
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows.map(mapProject);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCanvasProject(userId: string, title = '未命名画布'): Promise<CanvasProject> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const result = await client.query(
|
||||||
|
`INSERT INTO canvas_projects (id, user_id, title, state)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb)
|
||||||
|
RETURNING id, user_id, title, state, created_at, updated_at`,
|
||||||
|
[id, userId, title.trim() || '未命名画布', JSON.stringify(createEmptyCanvasState())],
|
||||||
|
);
|
||||||
|
return mapProject(result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCanvasProject(userId: string, id: string): Promise<CanvasProject | null> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT id, user_id, title, state, created_at, updated_at
|
||||||
|
FROM canvas_projects
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
LIMIT 1`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCanvasProject(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
values: { title?: string; state?: CanvasProjectState },
|
||||||
|
): Promise<CanvasProject | null> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const current = await client.query(
|
||||||
|
'SELECT title, state FROM canvas_projects WHERE id = $1 AND user_id = $2 LIMIT 1',
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
if (current.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const title = typeof values.title === 'string' && values.title.trim()
|
||||||
|
? values.title.trim()
|
||||||
|
: current.rows[0].title;
|
||||||
|
const state = values.state ? normalizeCanvasState(values.state) : normalizeCanvasState(current.rows[0].state);
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE canvas_projects
|
||||||
|
SET title = $3, state = $4::jsonb, updated_at = now()
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING id, user_id, title, state, created_at, updated_at`,
|
||||||
|
[id, userId, title, JSON.stringify(state)],
|
||||||
|
);
|
||||||
|
return result.rows.length > 0 ? mapProject(result.rows[0]) : null;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCanvasProject(userId: string, id: string): Promise<boolean> {
|
||||||
|
await ensureCanvasSchema();
|
||||||
|
const client = await getDbClient();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
'DELETE FROM canvas_projects WHERE id = $1 AND user_id = $2',
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
return (result.rowCount || 0) > 0;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/lib/canvas-types.ts
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
export type CanvasNodeType = 'text' | 'image' | 'text2img' | 'img2img' | 'layeredImage' | 'frame';
|
||||||
|
|
||||||
|
export type CanvasViewport = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
zoom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasAsset = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
type: 'image';
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasConnection = {
|
||||||
|
id: string;
|
||||||
|
sourceNodeId: string;
|
||||||
|
targetNodeId: string;
|
||||||
|
label?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasLayer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'background' | 'element' | 'icon' | 'text' | 'effect';
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
assetUrl?: string;
|
||||||
|
text?: string;
|
||||||
|
color?: string;
|
||||||
|
opacity?: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNode = {
|
||||||
|
id: string;
|
||||||
|
type: CanvasNodeType;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
zIndex: number;
|
||||||
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
prompt?: string;
|
||||||
|
negativePrompt?: string;
|
||||||
|
text?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
referenceImage?: string;
|
||||||
|
outputImages?: string[];
|
||||||
|
selectedOutput?: string;
|
||||||
|
status?: 'idle' | 'generating' | 'failed' | 'succeeded';
|
||||||
|
error?: string;
|
||||||
|
params?: {
|
||||||
|
aspectRatio?: string;
|
||||||
|
resolution?: string;
|
||||||
|
count?: number;
|
||||||
|
strength?: number;
|
||||||
|
model?: string;
|
||||||
|
modelLabel?: string;
|
||||||
|
apiType?: 'stream' | 'sync';
|
||||||
|
};
|
||||||
|
layers?: CanvasLayer[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasProjectState = {
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
connections: CanvasConnection[];
|
||||||
|
assets: CanvasAsset[];
|
||||||
|
viewport: CanvasViewport;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasProject = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
state: CanvasProjectState;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEmptyCanvasState(): CanvasProjectState {
|
||||||
|
return {
|
||||||
|
nodes: [],
|
||||||
|
connections: [],
|
||||||
|
assets: [],
|
||||||
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -226,31 +226,42 @@ export async function parseCustomApiJsonWithProgress(
|
|||||||
const streamEvents: unknown[] = [];
|
const streamEvents: unknown[] = [];
|
||||||
let streamText = '';
|
let streamText = '';
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
const { value, done } = await reader.read();
|
while (true) {
|
||||||
if (value) buffer += decoder.decode(value, { stream: !done });
|
const { value, done } = await reader.read();
|
||||||
const lines = buffer.split(/\r?\n/);
|
if (value) buffer += decoder.decode(value, { stream: !done });
|
||||||
buffer = done ? '' : lines.pop() || '';
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = done ? '' : lines.pop() || '';
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
for (const rawLine of lines) {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
if (!line || line === 'data: [DONE]' || line === '[DONE]') continue;
|
if (!line || line === 'data: [DONE]' || line === '[DONE]') continue;
|
||||||
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
if (line.startsWith('event:')) {
|
||||||
if (!payload || payload === '[DONE]') continue;
|
const eventName = line.slice(6).trim();
|
||||||
try {
|
const progress = extractUpstreamProgress({ message: `event: ${eventName}` });
|
||||||
const parsed = JSON.parse(payload);
|
if (progress) await onProgress?.(progress);
|
||||||
lastJson = parsed;
|
continue;
|
||||||
streamEvents.push(parsed);
|
}
|
||||||
streamText += extractStreamingTextDelta(parsed);
|
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
|
||||||
const progress = extractUpstreamProgress(parsed);
|
if (!payload || payload === '[DONE]') continue;
|
||||||
if (progress) await onProgress?.(progress);
|
try {
|
||||||
} catch {
|
const parsed = JSON.parse(payload);
|
||||||
const progress = extractUpstreamProgress({ message: payload });
|
lastJson = parsed;
|
||||||
if (progress) await onProgress?.(progress);
|
streamEvents.push(parsed);
|
||||||
|
streamText += extractStreamingTextDelta(parsed);
|
||||||
|
const progress = extractUpstreamProgress(parsed);
|
||||||
|
if (progress) await onProgress?.(progress);
|
||||||
|
} catch {
|
||||||
|
const progress = extractUpstreamProgress({ message: payload });
|
||||||
|
if (progress) await onProgress?.(progress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!lastJson && !streamText) throw error;
|
||||||
|
console.warn('[Custom API Stream] stream ended with read error after receiving data:', error instanceof Error ? error.message : error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
@@ -273,5 +284,8 @@ export function parseCustomApiError(status: number, rawBody: string): string {
|
|||||||
if (status === 413 || /request entity too large|payload too large|content too large/i.test(trimmed)) {
|
if (status === 413 || /request entity too large|payload too large|content too large/i.test(trimmed)) {
|
||||||
return '参考图请求体过大,上游模型服务拒绝接收。平台已自动压缩参考图;如果仍失败,请减少参考图数量、上传更小图片,或让 API 供应商提高图生图上传限制。';
|
return '参考图请求体过大,上游模型服务拒绝接收。平台已自动压缩参考图;如果仍失败,请减少参考图数量、上传更小图片,或让 API 供应商提高图生图上传限制。';
|
||||||
}
|
}
|
||||||
|
if (status === 524 || /cloudflare|error code 524|a timeout occurred|origin web server timed out/i.test(trimmed)) {
|
||||||
|
return '上游 API 同步生图请求超时(Cloudflare 524)。请确认该供应商已开启流式生图或异步任务接口;高分辨率生图不要走会长时间无响应的同步接口。';
|
||||||
|
}
|
||||||
return trimmed || `HTTP ${status}`;
|
return trimmed || `HTTP ${status}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ type GenerationJobOptions = {
|
|||||||
onStatus?: (status: GenerationJobStatus) => void;
|
onStatus?: (status: GenerationJobStatus) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class GenerationJobStillRunningError extends Error {
|
||||||
|
status: GenerationJobStatus | null;
|
||||||
|
|
||||||
|
constructor(status: GenerationJobStatus | null) {
|
||||||
|
super('生成任务仍在执行,请稍后在创作历史中查看');
|
||||||
|
this.name = 'GenerationJobStillRunningError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getAuthToken(): string | null {
|
function getAuthToken(): string | null {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem('miaojing_auth');
|
const raw = window.localStorage.getItem('miaojing_auth');
|
||||||
@@ -76,9 +86,10 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
|
|||||||
status: 'queued',
|
status: 'queued',
|
||||||
} as GenerationJobStatus);
|
} as GenerationJobStatus);
|
||||||
|
|
||||||
const timeoutMs = options.timeoutMs ?? (type === 'video' ? 600_000 : 300_000);
|
const timeoutMs = options.timeoutMs ?? (type === 'video' ? 600_000 : 900_000);
|
||||||
const intervalMs = options.intervalMs ?? 2_000;
|
const intervalMs = options.intervalMs ?? 2_000;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
let lastStatus: GenerationJobStatus | null = null;
|
||||||
|
|
||||||
while (Date.now() - startedAt < timeoutMs) {
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
await sleep(intervalMs);
|
await sleep(intervalMs);
|
||||||
@@ -93,6 +104,7 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
|
|||||||
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
|
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
|
||||||
}
|
}
|
||||||
options.onStatus?.(statusData as GenerationJobStatus);
|
options.onStatus?.(statusData as GenerationJobStatus);
|
||||||
|
lastStatus = statusData as GenerationJobStatus;
|
||||||
|
|
||||||
if (statusData.status === 'succeeded') {
|
if (statusData.status === 'succeeded') {
|
||||||
return (statusData.result || {}) as T;
|
return (statusData.result || {}) as T;
|
||||||
@@ -102,5 +114,5 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('生成任务仍在执行,请稍后在创作历史中查看或重试');
|
throw new GenerationJobStillRunningError(lastStatus);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Logs,
|
Logs,
|
||||||
Loader2,
|
Loader2,
|
||||||
Menu,
|
Menu,
|
||||||
|
Package,
|
||||||
PlugZap,
|
PlugZap,
|
||||||
Receipt,
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -45,6 +46,7 @@ const OrderManagementTab = dynamic(() => import('@/components/admin/order-manage
|
|||||||
const PaymentTab = dynamic(() => import('@/components/admin/payment-tab'), { ssr: false });
|
const PaymentTab = dynamic(() => import('@/components/admin/payment-tab'), { ssr: false });
|
||||||
const AnnouncementTab = dynamic(() => import('@/components/admin/announcement-tab'), { ssr: false });
|
const AnnouncementTab = dynamic(() => import('@/components/admin/announcement-tab'), { ssr: false });
|
||||||
const DataManagementTab = dynamic(() => import('@/components/admin/data-management-tab'), { ssr: false });
|
const DataManagementTab = dynamic(() => import('@/components/admin/data-management-tab'), { ssr: false });
|
||||||
|
const SystemUpgradeTab = dynamic(() => import('@/components/admin/system-upgrade-tab'), { ssr: false });
|
||||||
const TaskManagementTab = dynamic(() => import('@/components/admin/task-management-tab'), { ssr: false });
|
const TaskManagementTab = dynamic(() => import('@/components/admin/task-management-tab'), { ssr: false });
|
||||||
const LogManagementTab = dynamic(() => import('@/components/admin/log-management-tab'), { ssr: false });
|
const LogManagementTab = dynamic(() => import('@/components/admin/log-management-tab'), { ssr: false });
|
||||||
const SettingsTab = dynamic(() => import('@/components/admin/settings-tab'), { ssr: false });
|
const SettingsTab = dynamic(() => import('@/components/admin/settings-tab'), { ssr: false });
|
||||||
@@ -58,6 +60,7 @@ type ConsoleView =
|
|||||||
| 'payment'
|
| 'payment'
|
||||||
| 'announcements'
|
| 'announcements'
|
||||||
| 'data'
|
| 'data'
|
||||||
|
| 'upgrade'
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
| 'logs'
|
| 'logs'
|
||||||
| 'settings';
|
| 'settings';
|
||||||
@@ -221,6 +224,7 @@ const VIEW_TITLES: Record<ConsoleView, { title: string; description: string }> =
|
|||||||
payment: { title: '支付配置', description: '配置可用支付方式' },
|
payment: { title: '支付配置', description: '配置可用支付方式' },
|
||||||
announcements: { title: '公告管理', description: '创建和维护站点弹窗公告' },
|
announcements: { title: '公告管理', description: '创建和维护站点弹窗公告' },
|
||||||
data: { title: '数据管理', description: '导出、导入与恢复业务数据' },
|
data: { title: '数据管理', description: '导出、导入与恢复业务数据' },
|
||||||
|
upgrade: { title: '系统升级', description: '上传升级包,执行热更新、冷更新与失败自动回滚' },
|
||||||
tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' },
|
tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' },
|
||||||
logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' },
|
logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' },
|
||||||
settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' },
|
settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' },
|
||||||
@@ -344,6 +348,7 @@ export default function ConsoleDashboardPage() {
|
|||||||
label: '系统',
|
label: '系统',
|
||||||
items: [
|
items: [
|
||||||
{ value: 'data', label: '数据管理', icon: Database },
|
{ value: 'data', label: '数据管理', icon: Database },
|
||||||
|
{ value: 'upgrade', label: '系统升级', icon: Package },
|
||||||
{ value: 'logs', label: '系统日志', icon: Logs },
|
{ value: 'logs', label: '系统日志', icon: Logs },
|
||||||
{ value: 'settings', label: '系统设置', icon: Settings },
|
{ value: 'settings', label: '系统设置', icon: Settings },
|
||||||
],
|
],
|
||||||
@@ -572,6 +577,8 @@ function ConsoleContent({
|
|||||||
return <AnnouncementTab />;
|
return <AnnouncementTab />;
|
||||||
case 'data':
|
case 'data':
|
||||||
return <DataManagementTab />;
|
return <DataManagementTab />;
|
||||||
|
case 'upgrade':
|
||||||
|
return <SystemUpgradeTab />;
|
||||||
case 'tasks':
|
case 'tasks':
|
||||||
return <TaskManagementTab />;
|
return <TaskManagementTab />;
|
||||||
case 'logs':
|
case 'logs':
|
||||||
|
|||||||