From f7060b056db59069b22e8cddadc8dcc6fb8ad71b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E5=AF=92?= <2596194220@qq.com> Date: Sun, 24 May 2026 22:35:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 .version 文件(JSON 格式:version/release_date/changelog) - 新增 lib/update.ts 更新逻辑(版本检查、git clone、docker compose) - 新增 /api/update/check 版本检查 API - 新增 /api/update/execute 执行更新 API(SSE 流式日志) - 新增设置页「版本更新」界面(含 changelog 展示) - Dockerfile 添加 git、docker CLI;compose 挂载 docker socket - .env.docker 添加 ONEDEV_ACCESS_TOKEN 配置 --- .env.docker | 4 + .version | 5 + Dockerfile | 5 + app/api/update/check/route.ts | 18 ++ app/api/update/execute/route.ts | 46 ++++ app/settings/layout.tsx | 7 +- app/settings/update/page.tsx | 380 ++++++++++++++++++++++++++++++++ docker-compose.yml | 4 + lib/update.ts | 302 +++++++++++++++++++++++++ scripts/update.sh | 87 ++++++++ 10 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 .version create mode 100644 app/api/update/check/route.ts create mode 100644 app/api/update/execute/route.ts create mode 100644 app/settings/update/page.tsx create mode 100644 lib/update.ts create mode 100644 scripts/update.sh diff --git a/.env.docker b/.env.docker index 92d7b75..46b83e1 100644 --- a/.env.docker +++ b/.env.docker @@ -9,3 +9,7 @@ DB_NAME=recipe_tools AI_API_KEY=your_api_key_here AI_BASE_URL=https://api.openai.com/v1 AI_MODEL=gpt-3.5-turbo + +# 版本更新配置 +# 在 OneDev 中创建 Access Token (Settings → Access Tokens → New Token, scope: Read code) +ONEDEV_ACCESS_TOKEN=Gtn1krR7tmv4x2c7xLupvW6OqgwWbmanpKonknpQ diff --git a/.version b/.version new file mode 100644 index 0000000..8acf2e6 --- /dev/null +++ b/.version @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "release_date": "2025-01-15", + "changelog": "修复了XXX问题,增加了YYY功能" +} diff --git a/Dockerfile b/Dockerfile index ce289d8..f0e0543 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,10 +45,15 @@ COPY --from=builder /app/.next/static ./.next/static # 复制配置文件 COPY --from=builder /app/config.json ./ +COPY --from=builder /app/.version ./ # 给 nextjs 用户授权整个目录 RUN chown -R nextjs:nodejs /app +# 安装 git(版本检查、拉取更新)和 docker CLI(构建/重启容器) +USER root +RUN apk add --no-cache git docker-cli docker-compose +RUN chown -R nextjs:nodejs /app USER nextjs EXPOSE 3000 diff --git a/app/api/update/check/route.ts b/app/api/update/check/route.ts new file mode 100644 index 0000000..d7e2fc5 --- /dev/null +++ b/app/api/update/check/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { checkForUpdate } from "@/lib/update"; + +// GET /api/update/check - 检查是否有可用更新 +export async function GET() { + try { + const versionInfo = await checkForUpdate(); + return NextResponse.json({ + success: true, + data: versionInfo, + }); + } catch (error) { + return NextResponse.json( + { success: false, error: "检查更新失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/update/execute/route.ts b/app/api/update/execute/route.ts new file mode 100644 index 0000000..6322098 --- /dev/null +++ b/app/api/update/execute/route.ts @@ -0,0 +1,46 @@ +import { executeUpdate } from "@/lib/update"; + +// POST /api/update/execute - 执行容器更新(SSE 流式输出日志) +export async function POST() { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + // 发送日志的辅助函数 + const sendLog = (message: string) => { + const data = JSON.stringify({ log: message }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + }; + + try { + const result = await executeUpdate(sendLog); + + // 发送最终结果 + const finalData = JSON.stringify({ + done: true, + success: result.success, + message: result.message, + }); + controller.enqueue(encoder.encode(`data: ${finalData}\n\n`)); + } catch (error) { + const errMsg = + error instanceof Error ? error.message : "未知错误"; + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ done: true, success: false, message: errMsg })}\n\n` + ) + ); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/app/settings/layout.tsx b/app/settings/layout.tsx index e1e1b92..5e904a4 100644 --- a/app/settings/layout.tsx +++ b/app/settings/layout.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import {usePathname} from "next/navigation"; -import {Bandage, ChevronRight, FileText, Globe, UtensilsCrossed} from "lucide-react"; +import {Bandage, ChevronRight, FileText, Globe, RefreshCw, UtensilsCrossed} from "lucide-react"; import {Button} from "@/components/ui/button"; const settingsMenu = [ @@ -21,6 +21,11 @@ const settingsMenu = [ icon: Bandage, href: "/settings/ai", }, + { + title: "版本更新", + icon: RefreshCw, + href: "/settings/update", + }, ]; export default function SettingsLayout({ diff --git a/app/settings/update/page.tsx b/app/settings/update/page.tsx new file mode 100644 index 0000000..28feede --- /dev/null +++ b/app/settings/update/page.tsx @@ -0,0 +1,380 @@ +"use client"; + +import {useState, useRef, useEffect, useCallback} from "react"; +import { + RefreshCw, + CheckCircle2, + AlertCircle, + Loader2, + ChevronDown, + Terminal, + Calendar, + FileText, +} from "lucide-react"; +import {Button} from "@/components/ui/button"; + +interface VersionFile { + version: string; + release_date: string; + changelog: string; +} + +interface VersionInfo { + current: VersionFile; + latest: VersionFile | null; + hasUpdate: boolean; + currentCommit?: string; + latestCommit?: string; + message?: string; +} + +type CheckState = "idle" | "checking" | "done" | "error"; +type UpdateState = "idle" | "updating" | "done" | "error"; + +export default function UpdatePage() { + const [versionInfo, setVersionInfo] = useState(null); + const [checkState, setCheckState] = useState("idle"); + const [updateState, setUpdateState] = useState("idle"); + const [logs, setLogs] = useState([]); + const [showLogs, setShowLogs] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + const logEndRef = useRef(null); + + useEffect(() => { + logEndRef.current?.scrollIntoView({behavior: "smooth"}); + }, [logs]); + + // 检查更新 + const handleCheckUpdate = useCallback(async () => { + setCheckState("checking"); + setErrorMsg(""); + setUpdateState("idle"); + + try { + const response = await fetch("/api/update/check"); + const result = await response.json(); + + if (result.success) { + setVersionInfo(result.data); + setCheckState("done"); + } else { + throw new Error(result.error || "检查更新失败"); + } + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : "检查更新失败"); + setCheckState("error"); + } + }, []); + + // 执行更新 + const handleExecuteUpdate = useCallback(async () => { + setUpdateState("updating"); + setLogs([]); + setShowLogs(true); + setErrorMsg(""); + + try { + const response = await fetch("/api/update/execute", { + method: "POST", + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("无法读取响应流"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const {done, value} = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, {stream: true}); + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6); + + try { + const parsed = JSON.parse(data); + + if (parsed.log) { + setLogs((prev) => [...prev, parsed.log]); + } + + if (parsed.done) { + setUpdateState(parsed.success ? "done" : "error"); + if (!parsed.success) { + setErrorMsg(parsed.message || "更新失败"); + } + } + } catch { + // ignore + } + } + } + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : "更新执行失败"); + setUpdateState("error"); + } + }, []); + + // 解析 changelog 为列表项显示 + const parseChangelog = (text: string): string[] => { + if (!text) return []; + // 支持逗号、分号、换行分隔,也支持 "xxx1,xxx2" 中文逗号 + return text + .split(/[,;,;\n]/) + .map((s) => s.trim()) + .filter(Boolean); + }; + + return ( +
+
+

版本更新

+

检查并更新到最新版本

+
+ + {/* 版本信息卡片 */} +
+
+
+ {/* 当前版本 */} +
+
+ 当前版本 +
+
+ + {versionInfo?.current.version || "-"} + + {versionInfo?.current.release_date && ( + + + {versionInfo.current.release_date} + + )} +
+
+ + {/* 最新版本 */} + {versionInfo && ( +
+
+ 最新版本 + {versionInfo.hasUpdate && ( + + + 有新版本 + + )} + {checkState === "done" && !versionInfo.hasUpdate && ( + + + 已是最新 + + )} +
+ + {versionInfo.latest ? ( +
+ + {versionInfo.latest.version} + + {versionInfo.latest.release_date && ( + + + {versionInfo.latest.release_date} + + )} +
+ ) : versionInfo.message ? ( +
+
{versionInfo.message}
+
+ ) : ( +

+ {checkState === "checking" ? "检查中..." : "无法获取远程版本信息"} +

+ )} +
+ )} +
+ + {/* 操作按钮 */} +
+ {checkState === "checking" ? ( + + ) : ( + + )} + + +
+
+
+ + {/* 变更日志 - 有更新时展示 */} + {versionInfo?.latest?.changelog && ( +
+
+ + 更新内容 +
+
+
    + {parseChangelog(versionInfo.latest.changelog).map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+ )} + + {/* 当前版本自己的 changelog(本地) */} + {versionInfo?.current.changelog && !versionInfo?.latest?.changelog && ( +
+
+ + 当前版本更新内容 +
+
+
    + {parseChangelog(versionInfo.current.changelog).map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+ )} + + {/* 错误提示 */} + {errorMsg && ( +
+ + {errorMsg} +
+ )} + + {/* 状态提示 */} + {checkState === "idle" && !versionInfo && ( +
+ +

点击"检查更新"查看是否有新版本可用

+

+ 更新功能需要在 Docker 容器中运行,且已配置 + ONEDEV_ACCESS_TOKEN +

+
+ )} + + {/* 更新执行日志 */} + {showLogs && ( +
+ + + {logs.length > 0 && ( +
+
+                {logs.map((log, i) => (
+                  
{log}
+ ))} + {updateState === "updating" && ( +
+ + 执行中... +
+ )} + {updateState === "done" && ( +
✓ 更新完成
+ )} + {updateState === "error" && ( +
✗ 更新失败
+ )} +
+
+
+ )} +
+ )} + + {/* 完成状态 */} + {updateState === "done" && ( +
+ +
+

更新完成

+

服务已重启,请刷新页面以加载最新版本

+
+ +
+ )} +
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 1b2ec57..c184a50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: restart: unless-stopped ports: - "3000:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock environment: - NODE_ENV=production - DB_HOST=${DB_HOST} @@ -17,3 +19,5 @@ services: - AI_API_KEY=${AI_API_KEY} - AI_BASE_URL=${AI_BASE_URL:-https://api.openai.com/v1} - AI_MODEL=${AI_MODEL:-gpt-3.5-turbo} + - ONEDEV_ACCESS_TOKEN=${ONEDEV_ACCESS_TOKEN} + - GIT_REMOTE_URL=https://git.hanhan.ltd/recipe_tool.git diff --git a/lib/update.ts b/lib/update.ts new file mode 100644 index 0000000..8d29b7d --- /dev/null +++ b/lib/update.ts @@ -0,0 +1,302 @@ +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; + +// ============================================================ +// 版本更新模块 +// 用于检查远程仓库版本并执行 Docker 容器更新 +// ============================================================ + +/** .version JSON 文件结构 */ +export interface VersionFile { + version: string; + release_date: string; + changelog: string; +} + +/** 版本信息(API 返回给前端) */ +export interface VersionInfo { + current: VersionFile; + latest: VersionFile | null; + hasUpdate: boolean; + currentCommit?: string; + latestCommit?: string; + /** 状态消息,如 "远程仓库未包含 .version 文件" */ + message?: string; +} + +/** 默认版本文件 */ +const DEFAULT_VERSION: VersionFile = { + version: "0.0.0", + release_date: "", + changelog: "", +}; + +/** 更新配置 */ +interface UpdateConfig { + remoteUrl: string; + accessToken: string; +} + +/** 更新日志回调 */ +export type LogCallback = (message: string) => void; + +/** 空回调 */ +const noopLog: LogCallback = () => {}; + +/** + * 读取并解析本地的 .version 文件 + */ +export function readLocalVersionFile(): VersionFile { + try { + const versionPath = path.join(process.cwd(), ".version"); + const content = fs.readFileSync(versionPath, "utf-8").trim(); + return JSON.parse(content) as VersionFile; + } catch { + return DEFAULT_VERSION; + } +} + +/** + * 获取更新配置 + */ +function getUpdateConfig(): UpdateConfig { + return { + remoteUrl: + process.env.GIT_REMOTE_URL || "https://git.hanhan.ltd/recipe_tool.git", + accessToken: process.env.ONEDEV_ACCESS_TOKEN || "", + }; +} + +/** + * 构建带认证的远程 URL + */ +function getAuthenticatedRemoteUrl(): string { + const config = getUpdateConfig(); + if (!config.accessToken) return config.remoteUrl; + const url = new URL(config.remoteUrl); + url.username = "access-token"; + url.password = config.accessToken; + return url.toString(); +} + +/** + * 通过 git ls-remote 获取远程最新 commit hash + */ +export function getRemoteCommitHash(): string | null { + try { + const url = getAuthenticatedRemoteUrl(); + const output = execSync(`git ls-remote "${url}" HEAD`, { + encoding: "utf-8", + timeout: 15000, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + }, + windowsHide: true, + }); + const match = output.match(/^([a-f0-9]+)\s+HEAD/m); + return match?.[1] || null; + } catch { + return null; + } +} + +/** + * 获取远程 .version 文件内容并解析为 VersionFile + */ +export function getRemoteVersionFile(): VersionFile | null { + // 尝试方式1: git archive + try { + const url = getAuthenticatedRemoteUrl(); + const output = execSync( + `git archive --remote="${url}" HEAD:.version 2>nul`, + { + encoding: "utf-8", + timeout: 15000, + windowsHide: true, + } + ); + const parsed = JSON.parse(output.trim()) as VersionFile; + if (parsed.version) return parsed; + } catch { + // fall through + } + + // 尝试方式2: 浅克隆 + return getRemoteVersionByClone(); +} + +/** + * 通过浅克隆获取远程 .version 文件(备选方案) + */ +function getRemoteVersionByClone(): VersionFile | null { + const tmpDir = path.join( + process.env.TEMP || "/tmp", + `recipe_tool_version_${Date.now()}` + ); + try { + const url = getAuthenticatedRemoteUrl(); + execSync( + `git clone --depth 1 --filter=blob:none --sparse "${url}" "${tmpDir}" 2>nul`, + { + encoding: "utf-8", + timeout: 30000, + windowsHide: true, + } + ); + execSync(`cd "${tmpDir}" && git sparse-checkout set .version 2>nul`, { + encoding: "utf-8", + timeout: 10000, + windowsHide: true, + }); + const versionPath = path.join(tmpDir, ".version"); + if (fs.existsSync(versionPath)) { + const content = fs.readFileSync(versionPath, "utf-8").trim(); + return JSON.parse(content) as VersionFile; + } + return null; + } catch { + return null; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} + +/** + * 比较两个语义化版本号 + * 返回: 1 = v1 > v2, -1 = v1 < v2, 0 = equal + */ +export function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split(".").map(Number); + const parts2 = v2.split(".").map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + return 0; +} + +/** + * 检查是否有可用更新 + */ +export async function checkForUpdate(): Promise { + const current = readLocalVersionFile(); + const remoteCommit = getRemoteCommitHash(); + + if (!remoteCommit) { + return { + current, + latest: null, + hasUpdate: false, + message: "无法连接到远程仓库,请检查网络和 ONEDEV_ACCESS_TOKEN 配置", + }; + } + + const remoteVersionFile = getRemoteVersionFile(); + + if (remoteVersionFile) { + const cmp = compareVersions(remoteVersionFile.version, current.version); + return { + current, + latest: remoteVersionFile, + hasUpdate: cmp > 0, + latestCommit: remoteCommit, + }; + } + + // 远程能连上但 .version 文件不存在或解析失败 + return { + current, + latest: null, + hasUpdate: false, + latestCommit: remoteCommit, + message: + "远程仓库中未找到 .version 文件。\n" + + "请确保 .version 文件已提交并推送到远程仓库:\n" + + " git add .version && git commit -m \"add version file\" && git push", + }; +} + +/** + * 执行 Docker 容器更新 + * @param onLog 日志回调 + */ +export async function executeUpdate( + onLog: LogCallback = noopLog +): Promise<{ success: boolean; message: string }> { + const tmpDir = path.join( + process.env.TEMP || "/tmp", + `recipe_tool_update_${Date.now()}` + ); + + try { + onLog("开始更新流程...\n"); + + // 1. 拉取最新代码 + const url = getAuthenticatedRemoteUrl(); + onLog(`[1/4] 克隆最新代码...\n`); + execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, { + encoding: "utf-8", + timeout: 120000, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + onLog(`✓ 代码拉取完成\n`); + + // 2. 生成 .env 文件 + onLog(`[2/4] 准备环境变量...\n`); + const envContent = [ + `DB_HOST=${process.env.DB_HOST || "127.0.0.1"}`, + `DB_PORT=${process.env.DB_PORT || "3306"}`, + `DB_USER=${process.env.DB_USER || "root"}`, + `DB_PASSWORD=${process.env.DB_PASSWORD || ""}`, + `DB_NAME=${process.env.DB_NAME || "recipe_tools"}`, + `AI_API_KEY=${process.env.AI_API_KEY || ""}`, + `AI_BASE_URL=${process.env.AI_BASE_URL || "https://api.openai.com/v1"}`, + `AI_MODEL=${process.env.AI_MODEL || "gpt-3.5-turbo"}`, + `ONEDEV_ACCESS_TOKEN=${process.env.ONEDEV_ACCESS_TOKEN || ""}`, + ].join("\n"); + fs.writeFileSync(path.join(tmpDir, ".env"), envContent, "utf-8"); + onLog(`✓ 环境变量已准备\n`); + + // 3. 构建新镜像 + onLog(`[3/4] 构建新 Docker 镜像...\n`); + execSync(`cd "${tmpDir}" && docker compose -p recipe_tool build`, { + encoding: "utf-8", + timeout: 600000, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + onLog(`✓ 镜像构建完成\n`); + + // 4. 重启服务 + onLog(`[4/4] 重启服务...\n`); + execSync(`cd "${tmpDir}" && docker compose -p recipe_tool up -d`, { + encoding: "utf-8", + timeout: 60000, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + onLog(`✓ 服务已重启\n`); + + return { success: true, message: "更新完成" }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "未知错误"; + onLog(`✗ 更新失败: ${errMsg}\n`); + return { success: false, message: errMsg }; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..b0a4224 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# ============================================================ +# 食谱翻译工具 - Docker 更新脚本 +# 用于在 Docker 容器中执行自动更新 +# +# 用法: +# export ONEDEV_ACCESS_TOKEN="your_token_here" +# ./scripts/update.sh +# ============================================================ + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log() { echo -e "${GREEN}[✓]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +err() { echo -e "${RED}[✗]${NC} $1"; } + +GIT_REMOTE="${GIT_REMOTE_URL:-https://git.hanhan.ltd/recipe_tool.git}" +TOKEN="${ONEDEV_ACCESS_TOKEN:-}" +TMP_DIR="/tmp/recipe_tool_update_$(date +%s)" + +# 检查必要环境 +if [ -z "$TOKEN" ]; then + err "ONEDEV_ACCESS_TOKEN 未设置" + exit 1 +fi + +# 构建带认证的远程 URL +AUTH_URL="https://access-token:${TOKEN}@$(echo ${GIT_REMOTE} | sed 's|https://||')" + +echo "========================================" +echo " 食谱翻译工具 - 自动更新" +echo "========================================" +echo "" + +# 1. 克隆最新代码 +echo "[1/4] 拉取最新代码..." +git clone --depth 1 "${AUTH_URL}" "${TMP_DIR}" 2>/dev/null || { + err "克隆仓库失败" + exit 1 +} +log "代码拉取完成" + +# 2. 生成 .env 文件 +echo "[2/4] 准备环境变量..." +cat > "${TMP_DIR}/.env" << EOF +DB_HOST=${DB_HOST:-127.0.0.1} +DB_PORT=${DB_PORT:-3306} +DB_USER=${DB_USER:-root} +DB_PASSWORD=${DB_PASSWORD:-} +DB_NAME=${DB_NAME:-recipe_tools} +AI_API_KEY=${AI_API_KEY:-} +AI_BASE_URL=${AI_BASE_URL:-https://api.openai.com/v1} +AI_MODEL=${AI_MODEL:-gpt-3.5-turbo} +ONEDEV_ACCESS_TOKEN=${TOKEN} +EOF +log "环境变量已准备" + +# 3. 构建新镜像 +echo "[3/4] 构建新 Docker 镜像..." +cd "${TMP_DIR}" +docker compose -p recipe_tool build || { + err "镜像构建失败" + exit 1 +} +log "镜像构建完成" + +# 4. 重启服务 +echo "[4/4] 重启服务..." +docker compose -p recipe_tool up -d || { + err "服务重启失败" + exit 1 +} +log "服务已重启" + +# 清理 +rm -rf "${TMP_DIR}" 2>/dev/null || true + +echo "" +echo "========================================" +echo -e "${GREEN} 更新完成!${NC}" +echo "========================================"