Compare commits

..

12 Commits

Author SHA1 Message Date
FengLee
8ae28e030d Add admin upgrade package preflight 2026-05-10 00:18:03 +08:00
FengLee
70656562b1 Persist PM2 process list after upgrades 2026-05-10 00:09:50 +08:00
FengLee
1a27177f51 Allow source local-storage route in upgrades 2026-05-10 00:05:08 +08:00
FengLee
66c82fd1ee Make admin upgrade restart non-blocking 2026-05-10 00:01:01 +08:00
FengLee
24be9c550b Add canvas workflow and harden data import 2026-05-09 23:54:18 +08:00
fenglee
1a0607fe8d docs: add detailed project readme 2026-05-09 16:45:30 +08:00
Codex
f2817ab8fd feat: improve admin upgrade logs 2026-05-09 08:07:24 +00:00
Codex
e072f219e4 fix: preserve upgrade runner logs 2026-05-09 07:55:12 +00:00
Codex
8ae0f57488 feat: add admin upgrade workflow 2026-05-09 07:52:57 +00:00
Codex
24eab34305 Handle long running custom image jobs 2026-05-09 06:21:38 +00:00
Codex
c8f0c37cd1 Prefer streaming for custom image generation 2026-05-09 05:42:33 +00:00
Codex
234da90ac6 Fix profile auth token handling 2026-05-09 03:54:46 +00:00
30 changed files with 5327 additions and 59 deletions

613
README.md
View File

@@ -1,2 +1,613 @@
# miaojingAI
# 妙境 AI 创作平台
妙境是一个面向个人创作者、内容团队和私有化部署场景的 AI 多模态创作平台。平台围绕“文生图、图生图、文生视频、图生视频、图片反推提示词”构建完整创作链路,提供用户体系、积分/会员、订单支付、作品历史、公开画廊、模型供应商管理、系统配置、数据备份和在线升级能力。
项目基于 Next.js App Router、React、PostgreSQL、本地文件存储和 PM2 运行,支持本地 PostgreSQL 部署,也兼容 Supabase 作为数据库/认证底座。AI 生成能力既支持系统默认供应商,也支持用户自定义 OpenAI/New API 兼容接口。
## 项目截图
以下截图来自开发服务器的真实页面,用于快速了解平台界面和核心工作流。
### 首页
![妙境首页](docs/images/home.png)
### 创作中心
![创作中心](docs/images/create.png)
### 作品画廊
![作品画廊](docs/images/gallery.png)
## 核心能力
### 创作能力
- 文生图:根据文本提示词生成图片,支持尺寸、比例、模型和提示词参数。
- 图生图:上传参考图后进行风格迁移、场景变换、细节重绘和创意延展。
- 文生视频:根据文字描述生成动态视频内容。
- 图生视频:基于静态图片生成动态视频。
- 图片反推提示词:从图片中提取提示词,支持普通提示词、复刻级像素提示词、像素级图生图、像素级文生图等模式。
- 生成任务队列:生成任务写入 `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

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
docs/images/gallery.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
docs/images/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -8,6 +8,7 @@
"backup:create": "bash ./scripts/backup-create.sh",
"backup:list": "bash ./scripts/backup-list.sh",
"backup:restore": "bash ./scripts/backup-restore.sh",
"upgrade:run": "node ./scripts/admin-upgrade-runner.mjs",
"db:patch": "bash ./scripts/apply-database-patch.sh",
"dev": "bash ./scripts/dev.sh",
"preinstall": "npx only-allow pnpm",
@@ -75,7 +76,9 @@
"tailwind-merge": "^2.6.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
"zod": "^4.3.5",
"@xyflow/react": "^12.10.2",
"ag-psd": "^30.1.1"
},
"devDependencies": {
"@react-dev-inspector/babel-plugin": "^2.0.1",

166
pnpm-lock.yaml generated
View File

@@ -105,6 +105,12 @@ importers:
'@supabase/supabase-js':
specifier: 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:
specifier: ^0.7.1
version: 0.7.1
@@ -1403,24 +1409,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.4':
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.2.4':
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.2.4':
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.4':
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
@@ -2638,6 +2648,9 @@ packages:
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
@@ -2650,6 +2663,9 @@ packages:
'@types/d3-scale@4.0.9':
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':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
@@ -2659,6 +2675,12 @@ packages:
'@types/d3-timer@3.0.2':
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':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -2833,41 +2855,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -2940,6 +2970,15 @@ packages:
'@xtuc/long@4.2.2':
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:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -2969,6 +3008,9 @@ packages:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
engines: {node: '>= 10.0.0'}
ag-psd@30.1.1:
resolution: {integrity: sha512-0GbWYR4Rvm1QnWCYeMiVbUJBXnSyTUKvNUK2tIIVDt/wrUVUL9pHTsnwqOTonEC2RRh5I/aUcGydc1LNgXfJWA==}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -3252,6 +3294,9 @@ packages:
class-variance-authority@0.7.1:
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:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -3388,6 +3433,14 @@ packages:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
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:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
@@ -3408,6 +3461,10 @@ packages:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
@@ -3424,6 +3481,16 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
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:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -5245,6 +5312,9 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -6368,6 +6438,21 @@ packages:
zod@4.3.6:
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:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -9034,6 +9119,10 @@ snapshots:
'@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-interpolate@3.0.4':
@@ -9046,6 +9135,8 @@ snapshots:
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-selection@3.0.11': {}
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
@@ -9054,6 +9145,15 @@ snapshots:
'@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':
dependencies:
'@types/ms': 2.1.0
@@ -9362,6 +9462,29 @@ snapshots:
'@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:
dependencies:
mime-types: 3.0.2
@@ -9381,6 +9504,11 @@ snapshots:
address@1.2.2: {}
ag-psd@30.1.1:
dependencies:
base64-js: 1.5.1
pako: 2.1.0
agent-base@7.1.4: {}
ajv-formats@2.1.1(ajv@8.18.0):
@@ -9686,6 +9814,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classcat@5.0.5: {}
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -9812,6 +9942,13 @@ snapshots:
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-format@3.1.2: {}
@@ -9830,6 +9967,8 @@ snapshots:
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
@@ -9844,6 +9983,23 @@ snapshots:
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: {}
data-uri-to-buffer@4.0.1: {}
@@ -11947,6 +12103,8 @@ snapshots:
package-manager-detector@1.6.0: {}
pako@2.1.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -13333,4 +13491,12 @@ snapshots:
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: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

564
scripts/admin-upgrade-runner.mjs Executable file
View 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('/');
}

View File

@@ -75,6 +75,7 @@ type ImportContext = {
apiKeyIdMap: Map<string, string>;
apiKeyOwnerIdMap: Map<string, string>;
columnCache: Map<string, Set<string>>;
defaultableColumnCache: Map<string, Set<string>>;
};
export async function POST(request: NextRequest) {
@@ -136,11 +137,15 @@ async function importRows(
let skipped = 0;
const errors: string[] = [];
const existingColumns = await getExistingColumns(client, table, context);
const defaultableColumns = await getDefaultableColumns(client, table, context);
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
for (const rawRow of rows) {
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) {
skipped++;
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
@@ -235,7 +240,15 @@ async function buildImportContext(
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
}
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
? (userIdMap.get(ownerId) || ownerId)
: 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>> {
@@ -331,6 +352,12 @@ async function normalizeImportRow(table: string, row: Record<string, unknown>, c
}
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 rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
const secret = rawApiKey || rawEncrypted;
@@ -519,6 +546,29 @@ async function getExistingColumns(
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 {
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
map.set(value, crypto.randomUUID());

View 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';
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -22,7 +22,7 @@ interface CustomApiConfig {
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 MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
@@ -202,7 +202,7 @@ async function fetchCustomImageGeneration(
endpoint,
{ method: 'POST', headers: buildCustomApiHeaders(apiKey), body: JSON.stringify(requestBody) },
GENERATION_TIMEOUT,
1,
0,
);
if (!response.ok) {
@@ -396,6 +396,30 @@ function objectKeysFromUnknown(value: unknown): string[] {
*/
function extractImagesFromGenerationsResponse(data: Record<string, unknown>): 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)) {
for (const item of data.data as Array<Record<string, unknown>>) {
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') {
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 */
@@ -445,7 +479,7 @@ async function tryImageStrategy(
body: JSON.stringify(body),
},
GENERATION_TIMEOUT,
1,
0,
);
if (response.ok) {
@@ -511,6 +545,7 @@ async function tryEditsWithFormData(
const textFields: Record<string, string> = {
model,
prompt,
stream: 'true',
};
if (size) textFields.size = size;
if (count > 1) textFields.n = String(count);
@@ -547,7 +582,7 @@ async function tryEditsWithFormData(
body: bodyBuffer,
},
GENERATION_TIMEOUT,
1,
0,
);
if (response.ok) {
@@ -700,7 +735,7 @@ async function customApiImageToImage(
const chatUrl = deriveChatCompletionsUrl(endpoint);
const chatBody: Record<string, unknown> = {
model: customApiConfig.modelName,
stream: false,
stream: true,
messages: [
{
role: 'user',
@@ -734,6 +769,7 @@ async function customApiImageToImage(
n: count,
size: size || '1024x1024',
response_format: 'b64_json',
stream: true,
init_image: rawBase64,
denoising_strength: denoisingStrength,
};
@@ -871,6 +907,7 @@ export async function POST(request: NextRequest) {
n,
size: size || '1024x1024',
response_format: 'b64_json',
stream: true,
};
if (negativePrompt) {
requestBody.negative_prompt = negativePrompt;
@@ -889,6 +926,7 @@ export async function POST(request: NextRequest) {
'| size:', requestBody.size,
'| n:', requestBody.n,
'| aspect_ratio:', requestBody.aspect_ratio,
'| stream:', requestBody.stream,
'| guidance_scale:', requestBody.guidance_scale,
'| prompt_length:', prompt.length,
'| augmented_prompt_length:', augmentedPrompt.length);

5
src/app/canvas/page.tsx Executable file
View File

@@ -0,0 +1,5 @@
import { InfiniteCanvasWorkspace } from '@/components/canvas/infinite-canvas-workspace';
export default function CanvasPage() {
return <InfiniteCanvasWorkspace />;
}

View File

@@ -17,7 +17,7 @@ import { useCustomApiKeys } from '@/lib/custom-api-store';
import { useCreationHistory, type CreationRecord, isPlaceholder } from '@/lib/creation-history-store';
import { useCreditRecords, formatRecordTime } from '@/lib/credit-records-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 { CreationDetailDialog } from '@/components/creation-detail-dialog';
import {
@@ -232,7 +232,11 @@ export default function ProfilePage() {
};
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: '请先登录后再修改资料' });
return;
}
@@ -268,7 +272,7 @@ export default function ProfilePage() {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(payload),
});
@@ -283,12 +287,12 @@ export default function ProfilePage() {
email: data.profile.email,
nickname: data.profile.nickname,
phone: data.profile.phone || null,
membershipTier: data.profile.membership_tier || user.membershipTier,
creditsBalance: data.profile.credits_balance ?? user.creditsBalance,
dailyQuotaUsed: data.profile.daily_quota_used ?? user.dailyQuotaUsed,
dailyQuotaLimit: data.profile.daily_quota_limit ?? user.dailyQuotaLimit,
avatarUrl: data.profile.avatar_url ?? user.avatarUrl,
createdAt: data.profile.created_at ?? user.createdAt,
membershipTier: data.profile.membership_tier || authUser.membershipTier,
creditsBalance: data.profile.credits_balance ?? authUser.creditsBalance,
dailyQuotaUsed: data.profile.daily_quota_used ?? authUser.dailyQuotaUsed,
dailyQuotaLimit: data.profile.daily_quota_limit ?? authUser.dailyQuotaLimit,
avatarUrl: data.profile.avatar_url ?? authUser.avatarUrl,
createdAt: data.profile.created_at ?? authUser.createdAt,
emailVerified: data.profile.email_verified === true,
emailVerifiedAt: data.profile.email_verified_at ?? null,
});
@@ -305,7 +309,8 @@ export default function ProfilePage() {
};
const handleSendProfileEmailCode = async () => {
if (!accessToken) {
const authToken = accessToken || readStoredAuth().accessToken;
if (!authToken) {
setAccountMessage({ type: 'error', text: '请先登录后再验证邮箱' });
return;
}
@@ -320,7 +325,7 @@ export default function ProfilePage() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ email: accountForm.email }),
});
@@ -337,7 +342,8 @@ export default function ProfilePage() {
};
const handleVerifyProfileEmail = async () => {
if (!accessToken) return;
const authToken = accessToken || readStoredAuth().accessToken;
if (!authToken) return;
if (!isEmail(accountForm.email) || !emailVerifyCode) {
setAccountMessage({ type: 'error', text: '请填写邮箱和验证码' });
return;
@@ -348,7 +354,7 @@ export default function ProfilePage() {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ email: accountForm.email, code: emailVerifyCode }),
});

View 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> tartar.gztgz </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">
srcpackage.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> .envnode_modules.gitbackupslocal-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 });
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -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 { addCreditRecord } from '@/lib/credit-records-store';
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 { toast } from 'sonner';
import Link from 'next/link';
@@ -355,7 +355,7 @@ export function ImageToImagePanel() {
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
'image',
requestBody,
{ timeoutMs: 300_000, onStatus: setGenerationJobStatus },
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
);
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
if (data.images && data.images.length > 0) {
@@ -387,7 +387,10 @@ export function ImageToImagePanel() {
setGenerationError(createGenerationError(data.error || '图片生成失败'));
}
} 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('请求超时,请尝试减少生成数量或降低分辨率'));
} else {
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));

View File

@@ -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 { addCreditRecord } from '@/lib/credit-records-store';
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 { toast } from 'sonner';
import Link from 'next/link';
@@ -227,7 +227,7 @@ export function TextToImagePanel() {
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
'image',
requestBody,
{ timeoutMs: 300_000, onStatus: setGenerationJobStatus },
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
);
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
if (data.images && data.images.length > 0) {
@@ -258,7 +258,10 @@ export function TextToImagePanel() {
setGenerationError(createGenerationError(data.error || '图片生成失败'));
}
} 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('请求超时,请尝试减少生成数量或降低分辨率'));
} else {
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));

View File

@@ -17,6 +17,7 @@ import {
Sparkles,
LogOut,
Shield,
PanelsTopLeft,
Moon,
Sun,
} from 'lucide-react';
@@ -26,6 +27,7 @@ const navItems = [
{ href: '/', label: '首页', icon: Sparkles },
{ href: '/create', label: '创作', icon: Brush },
{ href: '/gallery', label: '画廊', icon: LayoutGrid },
{ href: '/canvas', label: '画布', icon: PanelsTopLeft },
{ href: '/profile', label: '我的', icon: User },
];

View File

@@ -26,20 +26,36 @@ export interface AuthState {
const STORAGE_KEY = 'miaojing_auth';
const EVENT_KEY = 'miaojing_auth_updated';
function getStoredAuth(): AuthState {
export function readStoredAuth(): AuthState {
if (typeof window === 'undefined') {
return { user: null, accessToken: null, isLoggedIn: false };
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { user: null, accessToken: null, isLoggedIn: false };
const parsed = JSON.parse(raw) as AuthState;
return parsed;
const parsed = JSON.parse(raw) as Partial<AuthState> & { session?: { access_token?: unknown } };
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 {
return { user: null, accessToken: null, isLoggedIn: false };
}
}
function getStoredAuth(): AuthState {
return readStoredAuth();
}
function setStoredAuth(state: AuthState): void {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));

150
src/lib/canvas-store.ts Executable file
View 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
View 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 },
};
}

View File

@@ -226,6 +226,7 @@ export async function parseCustomApiJsonWithProgress(
const streamEvents: unknown[] = [];
let streamText = '';
try {
while (true) {
const { value, done } = await reader.read();
if (value) buffer += decoder.decode(value, { stream: !done });
@@ -235,6 +236,12 @@ export async function parseCustomApiJsonWithProgress(
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line === 'data: [DONE]' || line === '[DONE]') continue;
if (line.startsWith('event:')) {
const eventName = line.slice(6).trim();
const progress = extractUpstreamProgress({ message: `event: ${eventName}` });
if (progress) await onProgress?.(progress);
continue;
}
const payload = line.startsWith('data:') ? line.slice(5).trim() : line;
if (!payload || payload === '[DONE]') continue;
try {
@@ -252,6 +259,10 @@ export async function parseCustomApiJsonWithProgress(
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()) {
try {
@@ -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)) {
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}`;
}

View File

@@ -28,6 +28,16 @@ type GenerationJobOptions = {
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 {
try {
const raw = window.localStorage.getItem('miaojing_auth');
@@ -76,9 +86,10 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
status: 'queued',
} 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 startedAt = Date.now();
let lastStatus: GenerationJobStatus | null = null;
while (Date.now() - startedAt < timeoutMs) {
await sleep(intervalMs);
@@ -93,6 +104,7 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
}
options.onStatus?.(statusData as GenerationJobStatus);
lastStatus = statusData as GenerationJobStatus;
if (statusData.status === 'succeeded') {
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);
}

View File

@@ -20,6 +20,7 @@ import {
Logs,
Loader2,
Menu,
Package,
PlugZap,
Receipt,
RefreshCw,
@@ -45,6 +46,7 @@ const OrderManagementTab = dynamic(() => import('@/components/admin/order-manage
const PaymentTab = dynamic(() => import('@/components/admin/payment-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 SystemUpgradeTab = dynamic(() => import('@/components/admin/system-upgrade-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 SettingsTab = dynamic(() => import('@/components/admin/settings-tab'), { ssr: false });
@@ -58,6 +60,7 @@ type ConsoleView =
| 'payment'
| 'announcements'
| 'data'
| 'upgrade'
| 'tasks'
| 'logs'
| 'settings';
@@ -221,6 +224,7 @@ const VIEW_TITLES: Record<ConsoleView, { title: string; description: string }> =
payment: { title: '支付配置', description: '配置可用支付方式' },
announcements: { title: '公告管理', description: '创建和维护站点弹窗公告' },
data: { title: '数据管理', description: '导出、导入与恢复业务数据' },
upgrade: { title: '系统升级', description: '上传升级包,执行热更新、冷更新与失败自动回滚' },
tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' },
logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' },
settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' },
@@ -344,6 +348,7 @@ export default function ConsoleDashboardPage() {
label: '系统',
items: [
{ value: 'data', label: '数据管理', icon: Database },
{ value: 'upgrade', label: '系统升级', icon: Package },
{ value: 'logs', label: '系统日志', icon: Logs },
{ value: 'settings', label: '系统设置', icon: Settings },
],
@@ -572,6 +577,8 @@ function ConsoleContent({
return <AnnouncementTab />;
case 'data':
return <DataManagementTab />;
case 'upgrade':
return <SystemUpgradeTab />;
case 'tasks':
return <TaskManagementTab />;
case 'logs':