Compare commits
11 Commits
007e022b01
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 76ba61d5bb | |||
| bc83044afb | |||
| 0fd4ce0dc5 | |||
| 1e3b12f342 | |||
| d9601fa24d | |||
| 54d97fc5a1 | |||
| 4a4aef562a | |||
| d2970ab751 | |||
| fc656d22ee | |||
| 054150c7d0 | |||
| 6b739d1c6c |
@@ -35,7 +35,6 @@ yarn-error.log*
|
|||||||
coverage
|
coverage
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose*.yml
|
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ AI_MODEL=GLM-4.7-Flash
|
|||||||
# 版本更新配置
|
# 版本更新配置
|
||||||
# 在 OneDev 中创建 Access Token (Settings → Access Tokens → New Token, scope: Read code)
|
# 在 OneDev 中创建 Access Token (Settings → Access Tokens → New Token, scope: Read code)
|
||||||
ONEDEV_ACCESS_TOKEN=Gtn1krR7tmv4x2c7xLupvW6OqgwWbmanpKonknpQ
|
ONEDEV_ACCESS_TOKEN=Gtn1krR7tmv4x2c7xLupvW6OqgwWbmanpKonknpQ
|
||||||
|
REGISTRY_IMAGE=recipe_tool:latest
|
||||||
|
REPOSITORY_URL=https://git.hanhan.ltd/recipe_tool
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.4",
|
"version": "1.0.8",
|
||||||
"release_date": "2025-01-15",
|
"release_date": "2025-01-15",
|
||||||
"changelog": "将更新内容移入版本信息卡片内,移除独立 changelog 区域"
|
"changelog": "修复构建bug,排除_docker-compose__yml_导致构建失败.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ COPY --from=builder /app/.next/static ./.next/static
|
|||||||
COPY --from=builder /app/config.json ./
|
COPY --from=builder /app/config.json ./
|
||||||
COPY --from=builder /app/.version ./
|
COPY --from=builder /app/.version ./
|
||||||
COPY --from=builder /app/.env ./
|
COPY --from=builder /app/.env ./
|
||||||
|
COPY --from=builder /app/docker-compose.yml ./
|
||||||
|
|
||||||
# 给 nextjs 用户授权整个目录
|
# 给 nextjs 用户授权整个目录
|
||||||
RUN chown -R nextjs:nodejs /app
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
|||||||
+197
-194
@@ -4,103 +4,103 @@ import * as cheerio from "cheerio";
|
|||||||
|
|
||||||
// 餐食类型
|
// 餐食类型
|
||||||
enum MealTypeCode {
|
enum MealTypeCode {
|
||||||
BREAKFAST = "breakfast",
|
BREAKFAST = "breakfast",
|
||||||
FRUIT = "fruit",
|
FRUIT = "fruit",
|
||||||
LUNCH = "lunch",
|
LUNCH = "lunch",
|
||||||
SNACK = "snack",
|
SNACK = "snack",
|
||||||
SPECIAL = "special",
|
SPECIAL = "special",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语言名称项
|
// 语言名称项
|
||||||
interface LangName {
|
interface LangName {
|
||||||
zh: string;
|
zh: string;
|
||||||
en?: string;
|
en?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单个菜品
|
// 单个菜品
|
||||||
interface Dish {
|
interface Dish {
|
||||||
id: string;
|
id: string;
|
||||||
name: LangName;
|
name: LangName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单日食谱
|
// 单日食谱
|
||||||
interface DailyMeal {
|
interface DailyMeal {
|
||||||
dayCode: number;
|
dayCode: number;
|
||||||
mealCode: MealTypeCode;
|
mealCode: MealTypeCode;
|
||||||
dishList: Dish[];
|
dishList: Dish[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 一周食谱
|
// 一周食谱
|
||||||
interface WeeklyMenu {
|
interface WeeklyMenu {
|
||||||
menuId: string;
|
menuId: string;
|
||||||
weekNumber: string;
|
weekNumber: string;
|
||||||
title: LangName;
|
title: LangName;
|
||||||
days: { code: number; name: LangName }[];
|
days: { code: number; name: LangName }[];
|
||||||
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
|
mealCategories: { code: MealTypeCode; name: LangName; sort: number }[];
|
||||||
meals: DailyMeal[];
|
meals: DailyMeal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 餐食类型配置
|
// 餐食类型配置
|
||||||
const MEAL_TYPES = [
|
const MEAL_TYPES = [
|
||||||
{ code: MealTypeCode.BREAKFAST, label: "早餐" },
|
{code: MealTypeCode.BREAKFAST, label: "早餐"},
|
||||||
{ code: MealTypeCode.FRUIT, label: "早点/水果餐" },
|
{code: MealTypeCode.FRUIT, label: "早点/水果餐"},
|
||||||
{ code: MealTypeCode.LUNCH, label: "中餐" },
|
{code: MealTypeCode.LUNCH, label: "中餐"},
|
||||||
{ code: MealTypeCode.SPECIAL, label: "体弱儿餐" },
|
{code: MealTypeCode.SPECIAL, label: "体弱儿餐"},
|
||||||
{ code: MealTypeCode.SNACK, label: "午点" },
|
{code: MealTypeCode.SNACK, label: "午点"},
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEEK_DAYS = [
|
const WEEK_DAYS = [
|
||||||
{ code: 1, label: "周一" },
|
{code: 1, label: "周一"},
|
||||||
{ code: 2, label: "周二" },
|
{code: 2, label: "周二"},
|
||||||
{ code: 3, label: "周三" },
|
{code: 3, label: "周三"},
|
||||||
{ code: 4, label: "周四" },
|
{code: 4, label: "周四"},
|
||||||
{ code: 5, label: "周五" },
|
{code: 5, label: "周五"},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 Cheerio 从 HTML 中提取表格原始数据
|
* 使用 Cheerio 从 HTML 中提取表格原始数据
|
||||||
*/
|
*/
|
||||||
function extractRawDataFromHtml(html: string): string[][][] {
|
function extractRawDataFromHtml(html: string): string[][][] {
|
||||||
const tableData: string[][][] = [];
|
const tableData: string[][][] = [];
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
const rows = $("table tr");
|
const rows = $("table tr");
|
||||||
|
|
||||||
rows.each((rowIndex, row) => {
|
rows.each((rowIndex, row) => {
|
||||||
const rowData: string[][] = [];
|
const rowData: string[][] = [];
|
||||||
const cells = $(row).find("td");
|
const cells = $(row).find("td");
|
||||||
|
|
||||||
cells.each((cellIndex, cell) => {
|
cells.each((cellIndex, cell) => {
|
||||||
// 直接获取 HTML 并处理
|
// 直接获取 HTML 并处理
|
||||||
const rawHtml = $(cell).html() || "";
|
const rawHtml = $(cell).html() || "";
|
||||||
|
|
||||||
// 替换换行标签为真实换行符
|
// 替换换行标签为真实换行符
|
||||||
const processed = rawHtml
|
const processed = rawHtml
|
||||||
.replace(/<\/p><p[^>]*>/g, "\n") // </p><p> → 换行
|
.replace(/<\/p><p[^>]*>/g, "\n") // </p><p> → 换行
|
||||||
.replace(/<br\s*\/?>/gi, "\n")
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
.replace(/ /g, " ");
|
.replace(/ /g, " ");
|
||||||
|
|
||||||
// 移除剩余 HTML 标签
|
// 移除剩余 HTML 标签
|
||||||
const text = processed.replace(/<[^>]+>/g, "");
|
const text = processed.replace(/<[^>]+>/g, "");
|
||||||
|
|
||||||
// 按换行分割
|
// 按换行分割
|
||||||
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
||||||
|
|
||||||
// 对每行应用顿号/逗号/分号分割
|
// 对每行应用顿号/逗号/分号分割
|
||||||
const finalItems: string[] = [];
|
const finalItems: string[] = [];
|
||||||
lines.forEach((line) => {
|
lines.forEach((line) => {
|
||||||
finalItems.push(...splitCellContent(line));
|
finalItems.push(...splitCellContent(line));
|
||||||
});
|
});
|
||||||
|
|
||||||
rowData.push(finalItems);
|
rowData.push(finalItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rowData.length > 0) {
|
||||||
|
tableData.push(rowData);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rowData.length > 0) {
|
return tableData;
|
||||||
tableData.push(rowData);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return tableData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,173 +108,176 @@ function extractRawDataFromHtml(html: string): string[][][] {
|
|||||||
* 支持分隔符:顿号(、)、逗号(,)、分号(;)
|
* 支持分隔符:顿号(、)、逗号(,)、分号(;)
|
||||||
*/
|
*/
|
||||||
function splitCellContent(content: string): string[] {
|
function splitCellContent(content: string): string[] {
|
||||||
if (!content || content.trim() === "") return [];
|
if (!content || content.trim() === "") return [];
|
||||||
|
|
||||||
// 统一分隔符
|
// 统一分隔符
|
||||||
const normalized = content
|
const normalized = content
|
||||||
.replace(/;/g, "|")
|
.replace(/;/g, "|")
|
||||||
.replace(/,/g, "|")
|
.replace(/,/g, "|")
|
||||||
.replace(/、/g, "|")
|
.replace(/、/g, "|")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const parts = normalized.split("|");
|
const parts = normalized.split("|");
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确定餐食类型
|
* 确定餐食类型
|
||||||
*/
|
*/
|
||||||
function getMealType(mealName: string): MealTypeCode | null {
|
function getMealType(mealName: string): MealTypeCode | null {
|
||||||
if (mealName.includes("早") && !mealName.includes("早点")) return MealTypeCode.BREAKFAST;
|
const header = mealName.trim();
|
||||||
if (mealName.includes("水果") || mealName.includes("早点")) return MealTypeCode.FRUIT;
|
|
||||||
if (mealName.includes("中") || mealName.includes("午餐")) return MealTypeCode.LUNCH;
|
if (header.includes("早餐") || header.includes("早 餐")) return MealTypeCode.BREAKFAST;
|
||||||
if (mealName.includes("午点") || mealName.includes("点心")) return MealTypeCode.SNACK;
|
if (header.includes("早点") || header.includes("水果餐") || header.includes("早 点") || header.includes("早点/水果餐")) return MealTypeCode.FRUIT;
|
||||||
if (mealName.includes("体弱") || mealName.includes("晚")) return MealTypeCode.SPECIAL;
|
if (header.includes("中餐") || header.includes("中 餐")) return MealTypeCode.LUNCH;
|
||||||
return null;
|
if (header.includes("午点") || header.includes("午 点")) return MealTypeCode.SNACK;
|
||||||
|
if (header.includes("体弱儿餐") || header.includes("体弱儿")) return MealTypeCode.SPECIAL;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从表格数据转换为一周食谱结构
|
* 从表格数据转换为一周食谱结构
|
||||||
*/
|
*/
|
||||||
function transformTableToWeeklyMenu(tableData: string[][][]): WeeklyMenu {
|
function transformTableToWeeklyMenu(tableData: string[][][]): WeeklyMenu {
|
||||||
const meals: DailyMeal[] = [];
|
const meals: DailyMeal[] = [];
|
||||||
|
|
||||||
// 校验表格数据完整性
|
// 校验表格数据完整性
|
||||||
if (tableData.length < 2 || tableData[0].length < 2) {
|
if (tableData.length < 2 || tableData[0].length < 2) {
|
||||||
console.warn("警告:表格数据不完整");
|
console.warn("警告:表格数据不完整");
|
||||||
return createEmptyMenu();
|
return createEmptyMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取表头数据(星期几)
|
// 提取表头数据(星期几)
|
||||||
const weekdays = tableData[0].slice(1).map((cell) => cell[0] || "");
|
const weekdays = tableData[0].slice(1).map((cell) => cell[0] || "");
|
||||||
|
|
||||||
// 提取餐型行数据(从第二行开始)
|
// 提取餐型行数据(从第二行开始)
|
||||||
const mealTypeRows = tableData.slice(1);
|
const mealTypeRows = tableData.slice(1);
|
||||||
|
|
||||||
// 遍历每一天(列)
|
// 遍历每一天(列)
|
||||||
for (let dayIndex = 0; dayIndex < weekdays.length; dayIndex++) {
|
for (let dayIndex = 0; dayIndex < weekdays.length; dayIndex++) {
|
||||||
const dayName = weekdays[dayIndex];
|
const dayName = weekdays[dayIndex];
|
||||||
if (!dayName) continue;
|
if (!dayName) continue;
|
||||||
|
|
||||||
const columnIndex = dayIndex + 1; // 对应星期几的列索引
|
const columnIndex = dayIndex + 1; // 对应星期几的列索引
|
||||||
|
|
||||||
// 遍历每一行餐型
|
// 遍历每一行餐型
|
||||||
for (const row of mealTypeRows) {
|
for (const row of mealTypeRows) {
|
||||||
const mealName = row[0]?.[0] || ""; // 餐型名称
|
const mealName = row[0]?.[0] || ""; // 餐型名称
|
||||||
if (!mealName) continue;
|
if (!mealName) continue;
|
||||||
|
|
||||||
const mealType = getMealType(mealName);
|
const mealType = getMealType(mealName);
|
||||||
if (!mealType) continue;
|
if (!mealType) continue;
|
||||||
|
|
||||||
// 获取当前餐型的菜品列表
|
// 获取当前餐型的菜品列表
|
||||||
const mealItems = row[columnIndex] || [];
|
const mealItems = row[columnIndex] || [];
|
||||||
|
|
||||||
// 添加到当天餐食列表
|
// 添加到当天餐食列表
|
||||||
meals.push({
|
meals.push({
|
||||||
dayCode: dayIndex + 1,
|
dayCode: dayIndex + 1,
|
||||||
mealCode: mealType,
|
mealCode: mealType,
|
||||||
dishList: mealItems.map((item, idx) => ({
|
dishList: mealItems.map((item, idx) => ({
|
||||||
id: `${dayIndex + 1}-${mealType}-${idx + 1}`,
|
id: `${dayIndex + 1}-${mealType}-${idx + 1}`,
|
||||||
name: { zh: item },
|
name: {zh: item},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保每天每餐都有记录
|
||||||
|
for (const day of WEEK_DAYS) {
|
||||||
|
for (const meal of MEAL_TYPES) {
|
||||||
|
const exists = meals.find(
|
||||||
|
(m) => m.dayCode === day.code && m.mealCode === meal.code
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
meals.push({dayCode: day.code, mealCode: meal.code, dishList: []});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 days 数组,使用实际解析出的星期
|
||||||
|
const days = weekdays
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name, index) => ({code: index + 1, name: {zh: name}}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
menuId: `menu-${Date.now()}`,
|
||||||
|
weekNumber: "",
|
||||||
|
title: {zh: "食谱", en: "Recipe"},
|
||||||
|
days: days,
|
||||||
|
mealCategories: MEAL_TYPES.map((m, i) => ({
|
||||||
|
code: m.code,
|
||||||
|
name: {zh: m.label},
|
||||||
|
sort: i + 1,
|
||||||
})),
|
})),
|
||||||
});
|
meals: meals.sort((a, b) => {
|
||||||
}
|
if (a.dayCode !== b.dayCode) return a.dayCode - b.dayCode;
|
||||||
}
|
const orderMap: Record<MealTypeCode, number> = {
|
||||||
|
[MealTypeCode.BREAKFAST]: 0,
|
||||||
// 确保每天每餐都有记录
|
[MealTypeCode.FRUIT]: 1,
|
||||||
for (const day of WEEK_DAYS) {
|
[MealTypeCode.LUNCH]: 2,
|
||||||
for (const meal of MEAL_TYPES) {
|
[MealTypeCode.SPECIAL]: 3,
|
||||||
const exists = meals.find(
|
[MealTypeCode.SNACK]: 4,
|
||||||
(m) => m.dayCode === day.code && m.mealCode === meal.code
|
};
|
||||||
);
|
return orderMap[a.mealCode] - orderMap[b.mealCode];
|
||||||
if (!exists) {
|
}),
|
||||||
meals.push({ dayCode: day.code, mealCode: meal.code, dishList: [] });
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建 days 数组,使用实际解析出的星期
|
|
||||||
const days = weekdays
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((name, index) => ({ code: index + 1, name: { zh: name } }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
menuId: `menu-${Date.now()}`,
|
|
||||||
weekNumber: "",
|
|
||||||
title: { zh: "食谱", en: "Recipe" },
|
|
||||||
days: days,
|
|
||||||
mealCategories: MEAL_TYPES.map((m, i) => ({
|
|
||||||
code: m.code,
|
|
||||||
name: { zh: m.label },
|
|
||||||
sort: i + 1,
|
|
||||||
})),
|
|
||||||
meals: meals.sort((a, b) => {
|
|
||||||
if (a.dayCode !== b.dayCode) return a.dayCode - b.dayCode;
|
|
||||||
const orderMap: Record<MealTypeCode, number> = {
|
|
||||||
[MealTypeCode.BREAKFAST]: 0,
|
|
||||||
[MealTypeCode.FRUIT]: 1,
|
|
||||||
[MealTypeCode.LUNCH]: 2,
|
|
||||||
[MealTypeCode.SPECIAL]: 3,
|
|
||||||
[MealTypeCode.SNACK]: 4,
|
|
||||||
};
|
|
||||||
return orderMap[a.mealCode] - orderMap[b.mealCode];
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyMenu(): WeeklyMenu {
|
function createEmptyMenu(): WeeklyMenu {
|
||||||
return {
|
return {
|
||||||
menuId: `menu-${Date.now()}`,
|
menuId: `menu-${Date.now()}`,
|
||||||
weekNumber: "",
|
weekNumber: "",
|
||||||
title: { zh: "食谱", en: "Recipe" },
|
title: {zh: "食谱", en: "Recipe"},
|
||||||
days: WEEK_DAYS.map((d) => ({ code: d.code, name: { zh: d.label } })),
|
days: WEEK_DAYS.map((d) => ({code: d.code, name: {zh: d.label}})),
|
||||||
mealCategories: MEAL_TYPES.map((m, i) => ({
|
mealCategories: MEAL_TYPES.map((m, i) => ({
|
||||||
code: m.code,
|
code: m.code,
|
||||||
name: { zh: m.label },
|
name: {zh: m.label},
|
||||||
sort: i + 1,
|
sort: i + 1,
|
||||||
})),
|
})),
|
||||||
meals: [],
|
meals: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: "未上传文件" }, { status: 400 });
|
return NextResponse.json({error: "未上传文件"}, {status: 400});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.endsWith(".docx")) {
|
||||||
|
return NextResponse.json({error: "仅支持 .docx 格式"}, {status: 400});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件为 ArrayBuffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// 使用 mammoth 转换为 HTML(保留表格结构)
|
||||||
|
const {value: htmlContent} = await mammoth.convertToHtml({buffer});
|
||||||
|
|
||||||
|
// 使用 cheerio 提取表格数据
|
||||||
|
const tableData = extractRawDataFromHtml(htmlContent);
|
||||||
|
|
||||||
|
// 转换为结构化数据
|
||||||
|
const weeklyMenu = transformTableToWeeklyMenu(tableData);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: weeklyMenu,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Parse error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "文件解析失败", details: String(error)},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file.name.endsWith(".docx")) {
|
|
||||||
return NextResponse.json({ error: "仅支持 .docx 格式" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取文件为 ArrayBuffer
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
// 使用 mammoth 转换为 HTML(保留表格结构)
|
|
||||||
const { value: htmlContent } = await mammoth.convertToHtml({ buffer });
|
|
||||||
|
|
||||||
// 使用 cheerio 提取表格数据
|
|
||||||
const tableData = extractRawDataFromHtml(htmlContent);
|
|
||||||
|
|
||||||
// 转换为结构化数据
|
|
||||||
const weeklyMenu = transformTableToWeeklyMenu(tableData);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
data: weeklyMenu,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Parse error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "文件解析失败", details: String(error) },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export default function UpdatePage() {
|
|||||||
logEndRef.current?.scrollIntoView({behavior: "smooth"});
|
logEndRef.current?.scrollIntoView({behavior: "smooth"});
|
||||||
}, [logs]);
|
}, [logs]);
|
||||||
|
|
||||||
|
// 页面加载时自动检查更新
|
||||||
|
useEffect(() => {
|
||||||
|
handleCheckUpdate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 检查更新
|
// 检查更新
|
||||||
const handleCheckUpdate = useCallback(async () => {
|
const handleCheckUpdate = useCallback(async () => {
|
||||||
setCheckState("checking");
|
setCheckState("checking");
|
||||||
|
|||||||
+3
-1
@@ -6,7 +6,7 @@ services:
|
|||||||
container_name: recipe_tool_app
|
container_name: recipe_tool_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "8011:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
@@ -20,4 +20,6 @@ services:
|
|||||||
- AI_BASE_URL=${AI_BASE_URL:-https://api.openai.com/v1}
|
- AI_BASE_URL=${AI_BASE_URL:-https://api.openai.com/v1}
|
||||||
- AI_MODEL=${AI_MODEL:-gpt-3.5-turbo}
|
- AI_MODEL=${AI_MODEL:-gpt-3.5-turbo}
|
||||||
- ONEDEV_ACCESS_TOKEN=${ONEDEV_ACCESS_TOKEN}
|
- ONEDEV_ACCESS_TOKEN=${ONEDEV_ACCESS_TOKEN}
|
||||||
|
- REGISTRY_IMAGE=${REGISTRY_IMAGE:-recipe_tool:latest}
|
||||||
|
- REPOSITORY_URL=${REPOSITORY_URL:-https://git.hanhan.ltd/recipe_tool}
|
||||||
- GIT_REMOTE_URL=https://git.hanhan.ltd/recipe_tool.git
|
- GIT_REMOTE_URL=https://git.hanhan.ltd/recipe_tool.git
|
||||||
|
|||||||
+154
-102
@@ -1,6 +1,6 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { execSync } from "child_process";
|
import { spawn } from "child_process";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 版本更新模块
|
// 版本更新模块
|
||||||
@@ -57,38 +57,78 @@ function consoleErr(tag: string, msg: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行命令并记录到控制台
|
* 异步执行命令,实时流式输出(不阻塞事件循环)
|
||||||
|
* @param command 可执行文件
|
||||||
|
* @param args 参数列表
|
||||||
|
* @param tag 日志标签
|
||||||
|
* @param options.onLine 每行输出回调(用于 SSE 推送到前端)
|
||||||
*/
|
*/
|
||||||
function execSyncAndLog(
|
function execAsync(
|
||||||
command: string,
|
command: string,
|
||||||
|
args: string[],
|
||||||
tag: string,
|
tag: string,
|
||||||
options: Parameters<typeof execSync>[1] & { encoding?: "utf-8" } = {}
|
options?: {
|
||||||
): string {
|
timeout?: number;
|
||||||
consoleLog(tag, `执行: ${command}`);
|
cwd?: string;
|
||||||
try {
|
onLine?: (line: string) => void;
|
||||||
const stdout = execSync(command, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
windowsHide: true,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
const out = stdout?.trim();
|
|
||||||
if (out) {
|
|
||||||
const lines = out.split("\n").filter(Boolean);
|
|
||||||
// 最多打印 5 行,避免刷屏
|
|
||||||
lines.slice(0, 5).forEach((l) => consoleLog(tag, ` │ ${l}`));
|
|
||||||
if (lines.length > 5) consoleLog(tag, ` │ ... 共 ${lines.length} 行`);
|
|
||||||
}
|
|
||||||
consoleLog(tag, "✓ 完成");
|
|
||||||
return stdout || "";
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as Error & { stderr?: Buffer };
|
|
||||||
const stderr = e.stderr?.toString().trim();
|
|
||||||
if (stderr) {
|
|
||||||
stderr.split("\n").filter(Boolean).forEach((l) => consoleErr(tag, ` │ ${l}`));
|
|
||||||
}
|
|
||||||
consoleErr(tag, `失败: ${e.message}`);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const desc = `${command} ${args.join(" ")}`;
|
||||||
|
consoleLog(tag, `执行: ${desc}`);
|
||||||
|
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: options?.cwd,
|
||||||
|
timeout: options?.timeout,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let timedOut = false;
|
||||||
|
if (options?.timeout) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill();
|
||||||
|
reject(new Error(`命令超时 (${options.timeout}ms): ${desc}`));
|
||||||
|
}, options.timeout);
|
||||||
|
child.on("close", () => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleData = (data: Buffer, isStderr: boolean) => {
|
||||||
|
const str = data.toString();
|
||||||
|
const lines = str.split("\n").filter(Boolean);
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trimEnd();
|
||||||
|
if (!line) continue;
|
||||||
|
if (isStderr) {
|
||||||
|
consoleErr(tag, ` │ ${line.slice(0, 2000)}`);
|
||||||
|
} else {
|
||||||
|
consoleLog(tag, ` │ ${line.slice(0, 2000)}`);
|
||||||
|
}
|
||||||
|
options?.onLine?.(line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data: Buffer) => handleData(data, false));
|
||||||
|
child.stderr?.on("data", (data: Buffer) => handleData(data, true));
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (timedOut) return;
|
||||||
|
if (code === 0) {
|
||||||
|
consoleLog(tag, `✓ 完成`);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
consoleErr(tag, `失败 (exit=${code})`);
|
||||||
|
reject(new Error(`Command failed with exit code ${code}: ${desc}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (timedOut) return;
|
||||||
|
consoleErr(tag, `异常: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,19 +170,15 @@ function getAuthenticatedRemoteUrl(): string {
|
|||||||
/**
|
/**
|
||||||
* 通过 git ls-remote 获取远程最新 commit hash
|
* 通过 git ls-remote 获取远程最新 commit hash
|
||||||
*/
|
*/
|
||||||
export function getRemoteCommitHash(): string | null {
|
export async function getRemoteCommitHash(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const url = getAuthenticatedRemoteUrl();
|
const url = getAuthenticatedRemoteUrl();
|
||||||
const output = execSync(`git ls-remote "${url}" HEAD`, {
|
let stdout = "";
|
||||||
encoding: "utf-8",
|
await execAsync("git", ["ls-remote", url, "HEAD"], "git-ls-remote", {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
env: {
|
onLine: (line) => { stdout += line + "\n"; },
|
||||||
...process.env,
|
|
||||||
GIT_TERMINAL_PROMPT: "0",
|
|
||||||
},
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
});
|
||||||
const match = output.match(/^([a-f0-9]+)\s+HEAD/m);
|
const match = stdout.match(/^([a-f0-9]+)\s+HEAD/m);
|
||||||
return match?.[1] || null;
|
return match?.[1] || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -152,19 +188,16 @@ export function getRemoteCommitHash(): string | null {
|
|||||||
/**
|
/**
|
||||||
* 获取远程 .version 文件内容并解析为 VersionFile
|
* 获取远程 .version 文件内容并解析为 VersionFile
|
||||||
*/
|
*/
|
||||||
export function getRemoteVersionFile(): VersionFile | null {
|
export async function getRemoteVersionFile(): Promise<VersionFile | null> {
|
||||||
// 尝试方式1: git archive
|
// 尝试方式1: git archive
|
||||||
try {
|
try {
|
||||||
const url = getAuthenticatedRemoteUrl();
|
const url = getAuthenticatedRemoteUrl();
|
||||||
const output = execSync(
|
let stdout = "";
|
||||||
`git archive --remote="${url}" HEAD:.version 2>nul`,
|
await execAsync("git", ["archive", `--remote=${url}`, "HEAD:.version"], "git-archive", {
|
||||||
{
|
timeout: 15000,
|
||||||
encoding: "utf-8",
|
onLine: (line) => { stdout += line; },
|
||||||
timeout: 15000,
|
});
|
||||||
windowsHide: true,
|
const parsed = JSON.parse(stdout.trim()) as VersionFile;
|
||||||
}
|
|
||||||
);
|
|
||||||
const parsed = JSON.parse(output.trim()) as VersionFile;
|
|
||||||
if (parsed.version) return parsed;
|
if (parsed.version) return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
@@ -178,18 +211,15 @@ export function getRemoteVersionFile(): VersionFile | null {
|
|||||||
* 通过浅克隆获取远程 .version 文件(备选方案)
|
* 通过浅克隆获取远程 .version 文件(备选方案)
|
||||||
* OneDev 不支持 git archive --remote,改用完整浅克隆
|
* OneDev 不支持 git archive --remote,改用完整浅克隆
|
||||||
*/
|
*/
|
||||||
function getRemoteVersionByClone(): VersionFile | null {
|
async function getRemoteVersionByClone(): Promise<VersionFile | null> {
|
||||||
const tmpDir = path.join(
|
const tmpDir = path.join(
|
||||||
process.env.TEMP || "/tmp",
|
process.env.TEMP || "/tmp",
|
||||||
`recipe_tool_version_${Date.now()}`
|
`recipe_tool_version_${Date.now()}`
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const url = getAuthenticatedRemoteUrl();
|
const url = getAuthenticatedRemoteUrl();
|
||||||
execSync(`git clone --depth 1 "${url}" "${tmpDir}"`, {
|
await execAsync("git", ["clone", "--depth", "1", url, tmpDir], "git-clone", {
|
||||||
encoding: "utf-8",
|
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
windowsHide: true,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
});
|
||||||
const versionPath = path.join(tmpDir, ".version");
|
const versionPath = path.join(tmpDir, ".version");
|
||||||
if (fs.existsSync(versionPath)) {
|
if (fs.existsSync(versionPath)) {
|
||||||
@@ -234,7 +264,7 @@ export async function checkForUpdate(): Promise<VersionInfo> {
|
|||||||
const current = readLocalVersionFile();
|
const current = readLocalVersionFile();
|
||||||
consoleLog("检查", `当前版本: v${current.version} (${current.release_date || "无日期"})`);
|
consoleLog("检查", `当前版本: v${current.version} (${current.release_date || "无日期"})`);
|
||||||
|
|
||||||
const remoteCommit = getRemoteCommitHash();
|
const remoteCommit = await getRemoteCommitHash();
|
||||||
|
|
||||||
if (!remoteCommit) {
|
if (!remoteCommit) {
|
||||||
consoleErr("检查", "无法连接到远程仓库");
|
consoleErr("检查", "无法连接到远程仓库");
|
||||||
@@ -247,7 +277,7 @@ export async function checkForUpdate(): Promise<VersionInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
consoleLog("检查", `远程 HEAD: ${remoteCommit}`);
|
consoleLog("检查", `远程 HEAD: ${remoteCommit}`);
|
||||||
const remoteVersionFile = getRemoteVersionFile();
|
const remoteVersionFile = await getRemoteVersionFile();
|
||||||
|
|
||||||
if (remoteVersionFile) {
|
if (remoteVersionFile) {
|
||||||
const cmp = compareVersions(remoteVersionFile.version, current.version);
|
const cmp = compareVersions(remoteVersionFile.version, current.version);
|
||||||
@@ -279,10 +309,10 @@ export async function checkForUpdate(): Promise<VersionInfo> {
|
|||||||
/**
|
/**
|
||||||
* 检查 Docker 是否可用
|
* 检查 Docker 是否可用
|
||||||
*/
|
*/
|
||||||
function checkDockerAvailable(): { ok: boolean; message: string } {
|
async function checkDockerAvailable(): Promise<{ ok: boolean; message: string }> {
|
||||||
consoleLog("Docker", "检查 Docker 守护进程...");
|
consoleLog("Docker", "检查 Docker 守护进程...");
|
||||||
try {
|
try {
|
||||||
execSyncAndLog("docker info", "Docker");
|
await execAsync("docker", ["info"], "Docker");
|
||||||
consoleLog("Docker", "✓ Docker 正常");
|
consoleLog("Docker", "✓ Docker 正常");
|
||||||
return { ok: true, message: "" };
|
return { ok: true, message: "" };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -294,7 +324,7 @@ function checkDockerAvailable(): { ok: boolean; message: string } {
|
|||||||
"更新功能需要在 Docker 容器内运行,当前环境不支持。\n" +
|
"更新功能需要在 Docker 容器内运行,当前环境不支持。\n" +
|
||||||
"如需测试,请使用 Docker 部署后访问容器内的页面:\n" +
|
"如需测试,请使用 Docker 部署后访问容器内的页面:\n" +
|
||||||
" docker compose -p recipe_tool up -d\n" +
|
" docker compose -p recipe_tool up -d\n" +
|
||||||
"然后访问 http://localhost:3000/settings/update",
|
"然后访问 http://localhost:8011/settings/update",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,13 +336,23 @@ function checkDockerAvailable(): { ok: boolean; message: string } {
|
|||||||
export async function executeUpdate(
|
export async function executeUpdate(
|
||||||
onLog: LogCallback = noopLog
|
onLog: LogCallback = noopLog
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
const tmpDir = path.join(
|
const cwd = process.cwd(); // /app(容器内工作目录,含 docker-compose.yml)
|
||||||
process.env.TEMP || "/tmp",
|
const token = process.env.ONEDEV_ACCESS_TOKEN || "";
|
||||||
`recipe_tool_update_${Date.now()}`
|
const repoUrl = process.env.REPOSITORY_URL || "https://git.hanhan.ltd/recipe_tool";
|
||||||
);
|
const registryImage = process.env.REGISTRY_IMAGE || "recipe_tool:latest";
|
||||||
|
|
||||||
|
// 从 REPOSITORY_URL 提取 registry host 和 project path 以拼出完整镜像地址
|
||||||
|
// 例如: https://git.hanhan.ltd/recipe_tool → host=git.hanhan.ltd, path=recipe_tool
|
||||||
|
// 完整镜像: git.hanhan.ltd/recipe_tool/recipe_tool:latest
|
||||||
|
const repoUrlObj = new URL(repoUrl);
|
||||||
|
const registryHost = repoUrlObj.host;
|
||||||
|
const projectPath = repoUrlObj.pathname.replace(/^\/|\/$/g, "");
|
||||||
|
const fullImage = `${registryHost}/${projectPath}/${registryImage}`;
|
||||||
|
|
||||||
consoleLog("执行", "=== 开始执行更新 ===");
|
consoleLog("执行", "=== 开始执行更新 ===");
|
||||||
consoleLog("执行", `临时目录: ${tmpDir}`);
|
consoleLog("执行", `工作目录: ${cwd}, 镜像: ${fullImage}`);
|
||||||
|
|
||||||
|
const logLine = (line: string) => onLog(line);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onLog("开始更新流程");
|
onLog("开始更新流程");
|
||||||
@@ -320,7 +360,7 @@ export async function executeUpdate(
|
|||||||
// 0. 检查 Docker 是否可用
|
// 0. 检查 Docker 是否可用
|
||||||
onLog("[0/4] 检查 Docker 环境");
|
onLog("[0/4] 检查 Docker 环境");
|
||||||
consoleLog("执行", "步骤 0/4 — 检查 Docker 环境");
|
consoleLog("执行", "步骤 0/4 — 检查 Docker 环境");
|
||||||
const dockerCheck = checkDockerAvailable();
|
const dockerCheck = await checkDockerAvailable();
|
||||||
if (!dockerCheck.ok) {
|
if (!dockerCheck.ok) {
|
||||||
onLog(`✗ ${dockerCheck.message}`);
|
onLog(`✗ ${dockerCheck.message}`);
|
||||||
consoleErr("执行", "Docker 检查未通过,更新终止");
|
consoleErr("执行", "Docker 检查未通过,更新终止");
|
||||||
@@ -328,18 +368,30 @@ export async function executeUpdate(
|
|||||||
}
|
}
|
||||||
onLog("✓ Docker 环境正常");
|
onLog("✓ Docker 环境正常");
|
||||||
|
|
||||||
// 1. 拉取最新代码
|
// 1. 登录 OneDev 镜像仓库并拉取最新镜像
|
||||||
const url = getAuthenticatedRemoteUrl();
|
onLog("[1/4] 登录镜像仓库");
|
||||||
onLog("[1/4] 克隆最新代码");
|
consoleLog("执行", "步骤 1/4 — 登录 OneDev 镜像仓库");
|
||||||
consoleLog("执行", "步骤 1/4 — 克隆代码");
|
if (token) {
|
||||||
execSyncAndLog(
|
await execAsync(
|
||||||
`git clone --depth 1 "${url}" "${tmpDir}"`,
|
"sh",
|
||||||
"git-clone",
|
["-c", `echo "${token}" | docker login ${registryHost} -u access-token --password-stdin`],
|
||||||
{ timeout: 120000, stdio: ["ignore", "pipe", "pipe"] }
|
"docker-login",
|
||||||
);
|
{ timeout: 15000, onLine: logLine }
|
||||||
onLog("✓ 代码拉取完成");
|
);
|
||||||
|
} else {
|
||||||
|
onLog("⚠ 未配置 ONEDEV_ACCESS_TOKEN,尝试匿名拉取");
|
||||||
|
consoleLog("执行", "未配置 access token,尝试匿名拉取");
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 生成 .env 文件
|
onLog("[1/4] 拉取新镜像");
|
||||||
|
consoleLog("执行", "步骤 1/4 — 拉取镜像");
|
||||||
|
await execAsync("docker", ["pull", fullImage], "docker-pull", {
|
||||||
|
timeout: 300000,
|
||||||
|
onLine: logLine,
|
||||||
|
});
|
||||||
|
onLog("✓ 镜像拉取完成");
|
||||||
|
|
||||||
|
// 2. 生成 .env 配置文件(供 docker-compose 解析 ${VAR})
|
||||||
onLog("[2/4] 准备环境变量");
|
onLog("[2/4] 准备环境变量");
|
||||||
consoleLog("执行", "步骤 2/4 — 准备环境变量");
|
consoleLog("执行", "步骤 2/4 — 准备环境变量");
|
||||||
const envContent = [
|
const envContent = [
|
||||||
@@ -351,31 +403,38 @@ export async function executeUpdate(
|
|||||||
`AI_API_KEY=${process.env.AI_API_KEY || ""}`,
|
`AI_API_KEY=${process.env.AI_API_KEY || ""}`,
|
||||||
`AI_BASE_URL=${process.env.AI_BASE_URL || "https://api.openai.com/v1"}`,
|
`AI_BASE_URL=${process.env.AI_BASE_URL || "https://api.openai.com/v1"}`,
|
||||||
`AI_MODEL=${process.env.AI_MODEL || "gpt-3.5-turbo"}`,
|
`AI_MODEL=${process.env.AI_MODEL || "gpt-3.5-turbo"}`,
|
||||||
`ONEDEV_ACCESS_TOKEN=${process.env.ONEDEV_ACCESS_TOKEN || ""}`,
|
`ONEDEV_ACCESS_TOKEN=${token}`,
|
||||||
|
`REGISTRY_IMAGE=${registryImage}`,
|
||||||
|
`REPOSITORY_URL=${repoUrl}`,
|
||||||
|
`GIT_REMOTE_URL=${process.env.GIT_REMOTE_URL || "https://git.hanhan.ltd/recipe_tool.git"}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
fs.writeFileSync(path.join(tmpDir, ".env"), envContent, "utf-8");
|
fs.writeFileSync(path.join(cwd, ".env"), envContent, "utf-8");
|
||||||
consoleLog("执行", `.env 已写入 ${envContent.split("\n").length} 行`);
|
consoleLog("执行", `.env 已写入 ${envContent.split("\n").length} 行`);
|
||||||
onLog("✓ 环境变量已准备");
|
onLog("✓ 环境变量已准备");
|
||||||
|
|
||||||
// 3. 构建新镜像
|
// 3. 停止旧容器(释放端口),忽略失败
|
||||||
onLog("[3/4] 构建新 Docker 镜像");
|
onLog("[3/4] 停止旧容器");
|
||||||
consoleLog("执行", "步骤 3/4 — 构建镜像(可能耗时较长)");
|
consoleLog("执行", "步骤 3/4 — 停止旧容器");
|
||||||
execSyncAndLog(
|
try {
|
||||||
`cd "${tmpDir}" && docker-compose -p recipe_tool build`,
|
await execAsync("docker-compose", ["-p", "recipe_tool", "down", "--remove-orphans"], "docker-down", {
|
||||||
"docker-build",
|
cwd,
|
||||||
{ timeout: 600000, stdio: ["ignore", "pipe", "pipe"] }
|
timeout: 30000,
|
||||||
);
|
onLine: logLine,
|
||||||
onLog("✓ 镜像构建完成");
|
});
|
||||||
|
} catch {
|
||||||
|
consoleLog("执行", "旧容器停止(可能不存在,忽略)");
|
||||||
|
}
|
||||||
|
onLog("✓ 旧容器已停止");
|
||||||
|
|
||||||
// 4. 重启服务
|
// 4. 启动新容器(使用拉取的镜像)
|
||||||
onLog("[4/4] 重启服务");
|
onLog("[4/4] 启动新容器");
|
||||||
consoleLog("执行", "步骤 4/4 — 重启服务");
|
consoleLog("执行", "步骤 4/4 — 启动新容器");
|
||||||
execSyncAndLog(
|
await execAsync("docker-compose", ["-p", "recipe_tool", "up", "-d"], "docker-up", {
|
||||||
`cd "${tmpDir}" && docker-compose -p recipe_tool up -d`,
|
cwd,
|
||||||
"docker-up",
|
timeout: 60000,
|
||||||
{ timeout: 60000, stdio: ["ignore", "pipe", "pipe"] }
|
onLine: logLine,
|
||||||
);
|
});
|
||||||
onLog("✓ 服务已重启");
|
onLog("✓ 新容器已启动");
|
||||||
|
|
||||||
consoleLog("执行", "=== 更新成功 ===");
|
consoleLog("执行", "=== 更新成功 ===");
|
||||||
return { success: true, message: "更新完成" };
|
return { success: true, message: "更新完成" };
|
||||||
@@ -384,12 +443,5 @@ export async function executeUpdate(
|
|||||||
consoleErr("执行", `=== 更新失败: ${errMsg} ===`);
|
consoleErr("执行", `=== 更新失败: ${errMsg} ===`);
|
||||||
onLog(`✗ 更新失败: ${errMsg}`);
|
onLog(`✗ 更新失败: ${errMsg}`);
|
||||||
return { success: false, message: errMsg };
|
return { success: false, message: errMsg };
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
consoleLog("执行", `临时目录已清理: ${tmpDir}`);
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
allowBuilds:
|
||||||
|
msw: true
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: true
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|||||||
Reference in New Issue
Block a user