feat: improve database module and apply untranslated color to UI

This commit is contained in:
2026-04-13 01:01:52 +08:00
parent 250abaa453
commit 12282ae4b3
3 changed files with 95 additions and 176 deletions
+22 -9
View File
@@ -4,16 +4,20 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
# 国内镜像加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN corepack enable && pnpm install --frozen-lockfile
# 国内源 + 快速启用 pnpm
RUN corepack enable && \
npm config set registry https://registry.npmmirror.com && \
pnpm config set registry https://registry.npmmirror.com
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Stage 2: Runner
@@ -23,20 +27,29 @@ WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
# 国内镜像
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 创建用户
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy built application
# 复制构建产物
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set ownership
# ===================== 修复权限错误 =====================
# 复制项目根目录的配置文件
COPY --from=builder /app/config.json ./
# 给 nextjs 用户授权整个目录
RUN chown -R nextjs:nodejs /app
# ========================================================
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
+19 -128
View File
@@ -13,7 +13,7 @@ import {
Loader2,
X,
AlertCircle,
Download, Settings, Sparkles,
Download, Settings,
} from "lucide-react";
import Link from 'next/link'
import {Button} from "@/components/ui/button";
@@ -126,24 +126,6 @@ export default function RecipeTranslationPage() {
orientation: "landscape",
});
// 菜品浮框状态
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
dish: Dish | null;
dayCode: number;
mealCode: MealTypeCode;
}>({ visible: false, x: 0, y: 0, dish: null, dayCode: 0, mealCode: MealTypeCode.BREAKFAST });
const [aiTranslating, setAiTranslating] = useState(false);
// 关闭浮框
useEffect(() => {
const handleClick = () => setContextMenu((prev) => ({ ...prev, visible: false }));
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// 加载导出配置
useEffect(() => {
fetch("/api/config")
@@ -160,53 +142,6 @@ export default function RecipeTranslationPage() {
.catch(console.error);
}, []);
// 单个菜品 AI 翻译
const handleAiTranslateSingle = useCallback(async () => {
if (!contextMenu.dish || !contextMenu.dish.name.zh || !data) return;
setAiTranslating(true);
try {
const response = await fetch(`/api/ai/translate?word=${encodeURIComponent(contextMenu.dish.name.zh)}`);
const result = await response.json();
if (result.english) {
// 更新 data 中的翻译结果
setData((prevData) => {
if (!prevData) return null;
return {
...prevData,
meals: prevData.meals.map((meal) => {
if (meal.dayCode === contextMenu.dayCode && meal.mealCode === contextMenu.mealCode) {
return {
...meal,
dishList: meal.dishList.map((dish) => {
if (dish.id === contextMenu.dish?.id) {
return {
...dish,
name: { ...dish.name, en: result.english },
translated: true,
};
}
return dish;
}),
};
}
return meal;
}),
};
});
setContextMenu((prev) => ({ ...prev, visible: false }));
} else {
alert(result.error || "翻译失败");
}
} catch (error) {
console.error("AI translation error:", error);
alert("翻译失败,请检查 AI 配置");
} finally {
setAiTranslating(false);
}
}, [contextMenu.dish, contextMenu.dayCode, contextMenu.mealCode, data]);
// 收集所有未翻译的菜品名称
const getAllDishNames = useCallback((menu: WeeklyMenu): string[] => {
const names: string[] = [];
@@ -812,34 +747,23 @@ export default function RecipeTranslationPage() {
(m) => m.dayCode === day.code && m.mealCode === meal.code
);
return (
<td key={meal.code} className="px-4 py-3">
<td key={meal.code} className="px-4 py-3">
<div className="flex flex-col gap-1">
{mealData?.dishList.length ? (
mealData.dishList.map((dish) => (
<div
key={dish.id}
className="flex flex-col gap-0.5 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 -mx-1"
onClick={(e) => {
e.stopPropagation();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
dish,
dayCode: day.code,
mealCode: meal.code,
});
}}
className="flex flex-col gap-0.5 text-sm"
>
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
{dish.name.zh}
</span>
{dish.name.en && (
<span
className="text-xs text-muted-foreground">
{dish.name.en}
</span>
)}
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
{dish.name.zh}
</span>
{dish.name.en && (
<span
className="text-xs text-muted-foreground">
{dish.name.en}
</span>
)}
</div>
))
) : (
@@ -857,31 +781,20 @@ export default function RecipeTranslationPage() {
m.dayCode === day.code &&
m.mealCode === selectedMealType
);
return mealData?.dishList.length ? (
return mealData?.dishList.length ? (
<div className="flex flex-col gap-1">
{mealData.dishList.map((dish) => (
<div
key={dish.id}
className="flex flex-col gap-0.5 cursor-pointer hover:bg-muted/50 rounded px-1 -mx-1"
onClick={(e) => {
e.stopPropagation();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
dish,
dayCode: day.code,
mealCode: selectedMealType as MealTypeCode,
});
}}
className="flex flex-col gap-0.5"
>
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
{dish.name.zh}
</span>
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
{dish.name.zh}
</span>
{dish.name.en && (
<span className="text-xs text-muted-foreground">
{dish.name.en}
</span>
{dish.name.en}
</span>
)}
</div>
))}
@@ -900,28 +813,6 @@ return mealData?.dishList.length ? (
</div>
)}
</main>
{/* 菜品浮框 */}
{contextMenu.visible && contextMenu.dish && (
<div
className="fixed z-50 min-w-[160px] rounded-md border bg-background shadow-lg py-1"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-left hover:bg-muted/50 disabled:opacity-50"
onClick={() => handleAiTranslateSingle()}
disabled={aiTranslating || !contextMenu.dish?.name.zh}
>
{aiTranslating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4 text-primary" />
)}
<span>AI (Beta)</span>
</button>
</div>
)}
</div>
);
}
+53 -38
View File
@@ -1,16 +1,29 @@
import mysql from "mysql2/promise";
import {getConfig} from "@/lib/config";
// 获取配置文件中的数据链接
const dataBaseConfig = getConfig().db
const pool = mysql.createPool({
host: process.env.DB_HOST || "127.0.0.1",
port: parseInt(process.env.DB_PORT || "3306"),
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "",
database: process.env.DB_NAME || "recipe_tools",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
host: dataBaseConfig.host || "127.0.0.1",
port: dataBaseConfig.port || 3306,
user: dataBaseConfig.user || "root",
password: dataBaseConfig.password || "123456",
database: dataBaseConfig.database || "recipe_tools",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
console.log({
host: dataBaseConfig.host || "127.0.0.1",
port: dataBaseConfig.port || 3306,
user: dataBaseConfig.user || "root",
password: dataBaseConfig.password || "123456",
database: dataBaseConfig.database || "recipe_tools",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
export default pool;
/**
@@ -19,20 +32,20 @@ export default pool;
* @returns 翻译结果或 null
*/
export async function getTranslation(chineseName: string): Promise<string | null> {
try {
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
"SELECT english FROM words WHERE word = ? LIMIT 1",
[chineseName]
);
try {
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
"SELECT english FROM words WHERE word = ? LIMIT 1",
[chineseName]
);
if (rows.length > 0 && rows[0].english) {
return rows[0].english as string;
if (rows.length > 0 && rows[0].english) {
return rows[0].english as string;
}
return null;
} catch (error) {
console.error("Database query error:", error);
return null;
}
return null;
} catch (error) {
console.error("Database query error:", error);
return null;
}
}
/**
@@ -41,27 +54,29 @@ export async function getTranslation(chineseName: string): Promise<string | null
* @returns 翻译结果映射 { chineseName: englishName }
*/
export async function getTranslations(
chineseNames: string[]
chineseNames: string[]
): Promise<Map<string, string>> {
const result = new Map<string, string>();
const result = new Map<string, string>();
if (chineseNames.length === 0) return result;
if (chineseNames.length === 0) return result;
try {
const placeholders = chineseNames.map(() => "?").join(",");
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
`SELECT word, english FROM words WHERE word IN (${placeholders})`,
chineseNames
);
try {
const placeholders = chineseNames.map(() => "?").join(",");
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
`SELECT word, english
FROM words
WHERE word IN (${placeholders})`,
chineseNames
);
for (const row of rows) {
if (row.word && row.english) {
result.set(row.word as string, row.english as string);
}
for (const row of rows) {
if (row.word && row.english) {
result.set(row.word as string, row.english as string);
}
}
} catch (error) {
console.error("Database batch query error:", error);
}
} catch (error) {
console.error("Database batch query error:", error);
}
return result;
return result;
}