diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..983b9c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +build +dist +logs +uploads +.git +.gitignore +*.md +LICENSE +NOTICES.txt +electron-builder.yml +backup +env +docs +*.log +.env* diff --git a/README.md b/README.md index 802a9ab..1543293 100644 --- a/README.md +++ b/README.md @@ -375,11 +375,16 @@ pm2 monit # 监控面板 ~~交流群 4~~ + ~~交流群 5~~ -交流群 6: +~~交流群 6~~ -Toonflow Logo +~~交流群 7~~ + +交流群 8: + +Toonflow Logo

使用微信扫码添加,二维码过期可提交 Issues 提醒更新

--- @@ -400,11 +405,11 @@ Toonflow 基于 AGPL-3.0 协议开源发布,许可证详情:https://www.gnu. --- -# ⭐️ 星标历史 + # 🙏 致谢 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..bd7c07c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,120 @@ +# 构建阶段 +FROM node:24-alpine AS builder + +WORKDIR /app + +# 定义构建参数 +ARG GIT=github +ARG TAG="" +ARG BRANCH="" + +# 安装 git +RUN apk add --no-cache git + +RUN npm config set registry https://registry.npmmirror.com/ && \ + yarn config set registry https://registry.npmmirror.com/ + +# 根据参数选择仓库源,支持 TAG / BRANCH 切换 +# 优先级: TAG > BRANCH > 最新 tag > 默认分支 +RUN if [ "$GIT" = "gitee" ]; then \ + REPO_URL="https://gitee.com/HBAI-Ltd/Toonflow-app.git"; \ + else \ + REPO_URL="https://github.com/HBAI-Ltd/Toonflow-app.git"; \ + fi && \ + echo "Cloning from: $REPO_URL" && \ + git clone "$REPO_URL" . && \ + if [ -n "$TAG" ]; then \ + echo "Checking out specified tag: $TAG" && \ + git checkout "$TAG"; \ + elif [ -n "$BRANCH" ]; then \ + echo "Checking out specified branch: $BRANCH" && \ + git checkout "$BRANCH"; \ + else \ + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || git tag --sort=-v:refname | head -n 1) && \ + if [ -n "$LATEST_TAG" ]; then \ + echo "Checking out latest tag: $LATEST_TAG" && \ + git checkout "$LATEST_TAG"; \ + else \ + echo "No tags found, using default branch"; \ + fi; \ + fi && \ + echo "Current version:" && git describe --tags --always + +RUN yarn install --frozen-lockfile + +RUN yarn build + +# 生产阶段 +FROM node:24-alpine + +WORKDIR /app + +# 安装 nginx 和 supervisor +RUN apk add --no-cache nginx supervisor && \ + mkdir -p /var/lib/nginx/logs /var/log/nginx && \ + npm config set registry https://registry.npmmirror.com/ && \ + yarn config set registry https://registry.npmmirror.com/ && \ + npm install -g pm2 + +# 复制后端文件 +COPY --from=builder /app/build ./build +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/yarn.lock ./ + +# 复制静态页面到 nginx 目录 +COPY --from=builder /app/scripts/web /usr/share/nginx/html + +# 只安装生产依赖 +RUN yarn install --frozen-lockfile --production + +# 配置 nginx +RUN cat > /etc/nginx/http.d/default.conf << 'EOF' +server { + listen 80; + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } +} +EOF + +# 配置 nginx 主配置,日志输出到 stderr/stdout +RUN sed -i 's|error_log /var/log/nginx/error.log warn;|error_log /dev/stderr warn;|g' /etc/nginx/nginx.conf || true && \ + sed -i 's|access_log /var/log/nginx/access.log main;|access_log /dev/stdout main;|g' /etc/nginx/nginx.conf || true + +# 配置 supervisor +RUN cat > /etc/supervisord.conf << 'EOF' +[supervisord] +nodaemon=true +logfile=/var/log/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:app] +command=pm2-runtime start build/app.js --name app +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +environment=NODE_ENV=prod +EOF + +ENV NODE_ENV=prod + +EXPOSE 80 +EXPOSE 60000 + +# 启动时创建必要目录(防止 volume 挂载覆盖) +CMD sh -c "mkdir -p /var/log/nginx /var/lib/nginx/logs && exec supervisord -c /etc/supervisord.conf" diff --git a/docker/Dockerfile.local b/docker/Dockerfile.local new file mode 100644 index 0000000..3745495 --- /dev/null +++ b/docker/Dockerfile.local @@ -0,0 +1,94 @@ +# 本地构建阶段 - 使用本地源码,不从 git 克隆 +FROM node:24-alpine AS builder + +WORKDIR /app + +RUN npm config set registry https://registry.npmmirror.com/ && \ + yarn config set registry https://registry.npmmirror.com/ + +# 复制依赖文件 +COPY package.json yarn.lock ./ + +RUN yarn install --frozen-lockfile + +# 复制源码 +COPY tsconfig.json ./ +COPY src/ ./src/ +COPY scripts/ ./scripts/ + +RUN yarn build + +# 生产阶段 +FROM node:24-alpine + +WORKDIR /app + +# 安装 nginx 和 supervisor +RUN apk add --no-cache nginx supervisor && \ + mkdir -p /var/lib/nginx/logs /var/log/nginx && \ + npm config set registry https://registry.npmmirror.com/ && \ + yarn config set registry https://registry.npmmirror.com/ && \ + npm install -g pm2 + +# 复制后端文件 +COPY --from=builder /app/build ./build +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/yarn.lock ./ + +# 复制静态页面到 nginx 目录 +COPY --from=builder /app/scripts/web /usr/share/nginx/html + +# 只安装生产依赖 +RUN yarn install --frozen-lockfile --production + +# 配置 nginx +RUN cat > /etc/nginx/http.d/default.conf << 'EOF' +server { + listen 80; + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } +} +EOF + +# 配置 nginx 主配置,日志输出到 stderr/stdout +RUN sed -i 's|error_log /var/log/nginx/error.log warn;|error_log /dev/stderr warn;|g' /etc/nginx/nginx.conf || true && \ + sed -i 's|access_log /var/log/nginx/access.log main;|access_log /dev/stdout main;|g' /etc/nginx/nginx.conf || true + +# 配置 supervisor +RUN cat > /etc/supervisord.conf << 'EOF' +[supervisord] +nodaemon=true +logfile=/var/log/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:app] +command=pm2-runtime start build/app.js --name app +directory=/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +environment=NODE_ENV=prod +EOF + +ENV NODE_ENV=prod + +EXPOSE 80 +EXPOSE 60000 + +# 启动时创建必要目录(防止 volume 挂载覆盖) +CMD sh -c "mkdir -p /var/log/nginx /var/lib/nginx/logs && exec supervisord -c /etc/supervisord.conf" diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml new file mode 100644 index 0000000..cf54b76 --- /dev/null +++ b/docker/docker-compose.local.yml @@ -0,0 +1,24 @@ +# 本地打包测试用,使用本地源码构建 +# 用法: docker-compose -f docker/docker-compose.local.yml up -d --build + +services: + toonflow: + build: + context: .. + dockerfile: docker/Dockerfile.local + image: toonflow:local + container_name: toonflow-local + restart: unless-stopped + ports: + - "8080:80" + - "60000:60000" + environment: + - NODE_ENV=prod + volumes: + - ../logs:/var/log + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..d70050c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,26 @@ +services: + toonflow: + build: + context: .. + dockerfile: docker/Dockerfile + args: + GIT: ${GIT:-github} + TAG: ${TAG:-} + BRANCH: ${BRANCH:-} + image: toonflow:${TAG:-latest} + container_name: toonflow + restart: unless-stopped + ports: + - "80" + - "60000:60000" + environment: + - NODE_ENV=prod + volumes: + # 可选: 持久化日志 + - ../logs:/var/log + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/docs/chat6QR.jpg b/docs/chat6QR.jpg deleted file mode 100644 index be531c3..0000000 Binary files a/docs/chat6QR.jpg and /dev/null differ diff --git a/docs/chat8QR.jpg b/docs/chat8QR.jpg new file mode 100644 index 0000000..12cf4f3 Binary files /dev/null and b/docs/chat8QR.jpg differ diff --git a/electron-builder.yml b/electron-builder.yml index 20b083a..40b752b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -9,6 +9,7 @@ directories: files: - build/**/* - scripts/web/**/* + - env/**/* - package.json - node_modules/**/* - "!node_modules/**/*.{md,ts,map}" diff --git a/env/.env.dev b/env/.env.dev new file mode 100644 index 0000000..b8dd44a --- /dev/null +++ b/env/.env.dev @@ -0,0 +1,4 @@ +NODE_ENV=dev +PORT=60000 +OSSURL=http://127.0.0.1:60000/ + diff --git a/env/.env.prod b/env/.env.prod new file mode 100644 index 0000000..bbf0003 --- /dev/null +++ b/env/.env.prod @@ -0,0 +1,4 @@ +NODE_ENV=prod +PORT=60000 +OSSURL=http://127.0.0.1:60000/ + diff --git a/package.json b/package.json index bd626dc..b9f9a9f 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,15 @@ "dev": "nodemon --inspect --exec tsx src/app.ts", "dev:gui": "chcp 65001 && electronmon -r tsx scripts/main.ts", "lint": "tsc --noEmit", - "build": "tsx scripts/build.ts", + "build": "cross-env NODE_ENV=prod tsx scripts/build.ts", "pack": "electron-builder --dir", "dist": "yarn build && electron-builder", "dist:win": "yarn build && electron-builder --win", "dist:mac": "yarn build && electron-builder --mac", "dist:linux": "yarn build && electron-builder --linux", - "test": "node build/app.js", + "test": "cross-env NODE_ENV=prod node build/app.js", + "docker:build": "docker-compose -f docker/docker-compose.yml up -d --build", + "docker:local": "docker-compose -f docker/docker-compose.local.yml up -d --build", "debug:ai": "npx @ai-sdk/devtools", "license": "bun run scripts/license.ts" }, @@ -71,6 +73,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/license-checker": "^25.0.6", "@types/morgan": "^1.9.10", + "cross-env": "^10.1.0", "electron": "^40.0.0", "electron-builder": "^26.4.0", "electronmon": "^2.0.4", diff --git a/scripts/build.ts b/scripts/build.ts index 5f57955..11638e8 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,4 +1,23 @@ import esbuild from "esbuild"; +import fs from "fs"; +import path from "path"; + +// 打包默认使用 prod 环境变量 +if (!process.env.NODE_ENV) { + process.env.NODE_ENV = "prod"; +} + +// 自动创建 env 目录和环境变量文件(.gitignore 可能忽略了这些文件) +const envDir = path.resolve("env"); +const envFile = path.join(envDir, `.env.${process.env.NODE_ENV}`); +if (!fs.existsSync(envDir)) { + fs.mkdirSync(envDir, { recursive: true }); +} +if (!fs.existsSync(envFile)) { + const defaultEnv = `NODE_ENV=${process.env.NODE_ENV}\nPORT=60000\nOSSURL=http://127.0.0.1:60000/\n`; + fs.writeFileSync(envFile, defaultEnv, "utf8"); + console.log(`📄 已自动创建环境变量文件: ${envFile}`); +} const external = ["electron", "sqlite3", "better-sqlite3", "mysql", "mysql2", "pg", "pg-query-stream", "oracledb", "tedious", "mssql"]; diff --git a/src/env.ts b/src/env.ts index 3cfbfad..069a4ee 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,37 +1,35 @@ -import { readFileSync, existsSync, writeFileSync } from "fs"; +import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs"; +import path from "path"; -function createDefaultEnvFile(path: string) { - const defaultContent = ["# 环境变量配置", "NODE_ENV=dev"].join("\n"); - writeFileSync(path, defaultContent, { encoding: "utf8" }); - console.log(`[环境变量]: 已创建默认的 ${path}`); -} +// 默认环境变量(当 env 文件不存在时自动创建) +const defaultEnvValues: Record = { + dev: `NODE_ENV=dev\nPORT=60000\nOSSURL=http://127.0.0.1:60000/`, + prod: `NODE_ENV=prod\nPORT=60000\nOSSURL=http://127.0.0.1:60000/`, +}; -function loadDotenvESM(envPath = ".env.local") { - let finalPath: string; +//加载环境变量 +const env = process.env.NODE_ENV ?? "dev"; +if (!env) { + console.log("[环境变量为空]"); + process.exit(1); +} else { + const envDir = path.resolve("env"); + const envFilePath = path.join(envDir, `.env.${env}`); - if (typeof process.versions?.electron !== "undefined") { - const { app } = require("electron"); - finalPath = app.getPath("userData") + `/${envPath}`; - // 如果 userData 目录下的 env 文件不存在,则尝试当前目录 - if (!existsSync(finalPath)) { - finalPath = envPath; - } - } else { - finalPath = envPath; + // 自动创建 env 目录和文件(.gitignore 可能忽略了这些文件) + if (!existsSync(envDir)) { + mkdirSync(envDir, { recursive: true }); + } + if (!existsSync(envFilePath)) { + const content = defaultEnvValues[env] ?? defaultEnvValues.prod; + writeFileSync(envFilePath, content, "utf8"); + console.log(`[环境变量] 自动创建 ${envFilePath}`); } - // 若文件不存在,自动创建一个带默认内容的环境变量文件 - if (!existsSync(finalPath)) { - createDefaultEnvFile(finalPath); - } - - const text = readFileSync(finalPath, "utf8"); + const text = readFileSync(envFilePath, "utf8"); for (const line of text.split("\n")) { const idx = line.indexOf("="); if (idx > 0) process.env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); } - console.log(`[环境变量]: 已加载 ${finalPath}`); + console.log(`[环境变量] ${env}`); } - -// 若非 Electron 环境,则加载 .env.local -if (typeof process.versions?.electron == "undefined") loadDotenvESM(".env.local"); diff --git a/src/err.ts b/src/err.ts index 07d7770..34a3e66 100644 --- a/src/err.ts +++ b/src/err.ts @@ -1,10 +1,30 @@ +import { serializeError } from "serialize-error"; + // 处理未捕获的 Promise 拒绝 process.on('unhandledRejection', (reason, promise) => { - console.error('[未处理的 Promise 拒绝]:', reason); + console.error('[未处理的 Promise 拒绝]'); + if (reason instanceof Error) { + console.error('错误名称:', reason.name); + console.error('错误消息:', reason.message); + console.error('堆栈信息:', reason.stack); + console.error('序列化详情:', JSON.stringify(serializeError(reason), null, 2)); + } else { + console.error('原因:', reason); + console.error('类型:', typeof reason); + try { + console.error('JSON:', JSON.stringify(reason, null, 2)); + } catch { + console.error('(无法序列化)'); + } + } console.error('Promise:', promise); }); // 处理未捕获的异常 process.on('uncaughtException', (error) => { - console.error('[未捕获的异常]:', error); + console.error('[未捕获的异常]'); + console.error('错误名称:', error.name); + console.error('错误消息:', error.message); + console.error('堆栈信息:', error.stack); + console.error('序列化详情:', JSON.stringify(serializeError(error), null, 2)); }); diff --git a/src/lib/fixDB.ts b/src/lib/fixDB.ts index 285f906..16541d6 100644 --- a/src/lib/fixDB.ts +++ b/src/lib/fixDB.ts @@ -2,18 +2,21 @@ import { Knex } from "knex"; export default async (knex: Knex): Promise => { const addColumn = async (table: string, column: string, type: string) => { + if (!(await knex.schema.hasTable(table))) return; if (!(await knex.schema.hasColumn(table, column))) { await knex.schema.alterTable(table, (t) => (t as any)[type](column)); } }; const dropColumn = async (table: string, column: string) => { + if (!(await knex.schema.hasTable(table))) return; if (await knex.schema.hasColumn(table, column)) { await knex.schema.alterTable(table, (t) => t.dropColumn(column)); } }; const alterColumnType = async (table: string, column: string, type: string) => { + if (!(await knex.schema.hasTable(table))) return; if (await knex.schema.hasColumn(table, column)) { await knex.schema.alterTable(table, (t) => { (t as any)[type](column).alter(); diff --git a/src/utils/db.ts b/src/utils/db.ts index efca2e2..932fbe0 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -40,10 +40,11 @@ const db = knex({ useNullAsDefault: true, }); -initDB(db); -fixDB(db); - -if (process.env.NODE_ENV == "dev") initKnexType(db); +(async () => { + await initDB(db); + await fixDB(db); + if (process.env.NODE_ENV == "dev") initKnexType(db); +})(); const dbClient = Object.assign((table: TName) => db, RowType[]>(table), db); dbClient.schema = db.schema; diff --git a/yarn.lock b/yarn.lock index b3ba148..1397131 100644 --- a/yarn.lock +++ b/yarn.lock @@ -224,6 +224,11 @@ dependencies: tslib "^2.4.0" +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.npmmirror.com/@epic-web/invariant/-/invariant-1.0.0.tgz#1073e5dee6dd540410784990eb73e4acd25c9813" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== + "@esbuild/aix-ppc64@0.27.2": version "0.27.2" resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" @@ -1538,6 +1543,14 @@ crc@^3.8.0: dependencies: buffer "^5.1.0" +cross-env@^10.1.0: + version "10.1.0" + resolved "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz#cfd2a6200df9ed75bfb9cb3d7ce609c13ea21783" + integrity sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw== + dependencies: + "@epic-web/invariant" "^1.0.0" + cross-spawn "^7.0.6" + cross-spawn@^7.0.1, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"