feat: improve database module and apply untranslated color to UI
This commit is contained in:
+22
-9
@@ -4,16 +4,20 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
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 ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
# Install dependencies
|
# 国内源 + 快速启用 pnpm
|
||||||
RUN corepack enable && pnpm install --frozen-lockfile
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Runner
|
||||||
@@ -23,20 +27,29 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
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 && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copy built application
|
# 复制构建产物
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
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
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
|||||||
+19
-128
@@ -13,7 +13,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Download, Settings, Sparkles,
|
Download, Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
@@ -126,24 +126,6 @@ export default function RecipeTranslationPage() {
|
|||||||
orientation: "landscape",
|
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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/config")
|
fetch("/api/config")
|
||||||
@@ -160,53 +142,6 @@ export default function RecipeTranslationPage() {
|
|||||||
.catch(console.error);
|
.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 getAllDishNames = useCallback((menu: WeeklyMenu): string[] => {
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
@@ -812,34 +747,23 @@ export default function RecipeTranslationPage() {
|
|||||||
(m) => m.dayCode === day.code && m.mealCode === meal.code
|
(m) => m.dayCode === day.code && m.mealCode === meal.code
|
||||||
);
|
);
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-1">
|
||||||
{mealData?.dishList.length ? (
|
{mealData?.dishList.length ? (
|
||||||
mealData.dishList.map((dish) => (
|
mealData.dishList.map((dish) => (
|
||||||
<div
|
<div
|
||||||
key={dish.id}
|
key={dish.id}
|
||||||
className="flex flex-col gap-0.5 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 -mx-1"
|
className="flex flex-col gap-0.5 text-sm"
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setContextMenu({
|
|
||||||
visible: true,
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
dish,
|
|
||||||
dayCode: day.code,
|
|
||||||
mealCode: meal.code,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
||||||
{dish.name.zh}
|
{dish.name.zh}
|
||||||
</span>
|
</span>
|
||||||
{dish.name.en && (
|
{dish.name.en && (
|
||||||
<span
|
<span
|
||||||
className="text-xs text-muted-foreground">
|
className="text-xs text-muted-foreground">
|
||||||
{dish.name.en}
|
{dish.name.en}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -857,31 +781,20 @@ export default function RecipeTranslationPage() {
|
|||||||
m.dayCode === day.code &&
|
m.dayCode === day.code &&
|
||||||
m.mealCode === selectedMealType
|
m.mealCode === selectedMealType
|
||||||
);
|
);
|
||||||
return mealData?.dishList.length ? (
|
return mealData?.dishList.length ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{mealData.dishList.map((dish) => (
|
{mealData.dishList.map((dish) => (
|
||||||
<div
|
<div
|
||||||
key={dish.id}
|
key={dish.id}
|
||||||
className="flex flex-col gap-0.5 cursor-pointer hover:bg-muted/50 rounded px-1 -mx-1"
|
className="flex flex-col gap-0.5"
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setContextMenu({
|
|
||||||
visible: true,
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
dish,
|
|
||||||
dayCode: day.code,
|
|
||||||
mealCode: selectedMealType as MealTypeCode,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
<span style={{ color: dish.name.en ? undefined : exportConfig.untranslatedColor }}>
|
||||||
{dish.name.zh}
|
{dish.name.zh}
|
||||||
</span>
|
</span>
|
||||||
{dish.name.en && (
|
{dish.name.en && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{dish.name.en}
|
{dish.name.en}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -900,28 +813,6 @@ return mealData?.dishList.length ? (
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-38
@@ -1,16 +1,29 @@
|
|||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
|
import {getConfig} from "@/lib/config";
|
||||||
|
|
||||||
|
// 获取配置文件中的数据链接
|
||||||
|
const dataBaseConfig = getConfig().db
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST || "127.0.0.1",
|
host: dataBaseConfig.host || "127.0.0.1",
|
||||||
port: parseInt(process.env.DB_PORT || "3306"),
|
port: dataBaseConfig.port || 3306,
|
||||||
user: process.env.DB_USER || "root",
|
user: dataBaseConfig.user || "root",
|
||||||
password: process.env.DB_PASSWORD || "",
|
password: dataBaseConfig.password || "123456",
|
||||||
database: process.env.DB_NAME || "recipe_tools",
|
database: dataBaseConfig.database || "recipe_tools",
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0,
|
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;
|
export default pool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,20 +32,20 @@ export default pool;
|
|||||||
* @returns 翻译结果或 null
|
* @returns 翻译结果或 null
|
||||||
*/
|
*/
|
||||||
export async function getTranslation(chineseName: string): Promise<string | null> {
|
export async function getTranslation(chineseName: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
|
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
|
||||||
"SELECT english FROM words WHERE word = ? LIMIT 1",
|
"SELECT english FROM words WHERE word = ? LIMIT 1",
|
||||||
[chineseName]
|
[chineseName]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (rows.length > 0 && rows[0].english) {
|
if (rows.length > 0 && rows[0].english) {
|
||||||
return rows[0].english as string;
|
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 }
|
* @returns 翻译结果映射 { chineseName: englishName }
|
||||||
*/
|
*/
|
||||||
export async function getTranslations(
|
export async function getTranslations(
|
||||||
chineseNames: string[]
|
chineseNames: string[]
|
||||||
): Promise<Map<string, 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 {
|
try {
|
||||||
const placeholders = chineseNames.map(() => "?").join(",");
|
const placeholders = chineseNames.map(() => "?").join(",");
|
||||||
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
|
const [rows] = await pool.execute<mysql.RowDataPacket[]>(
|
||||||
`SELECT word, english FROM words WHERE word IN (${placeholders})`,
|
`SELECT word, english
|
||||||
chineseNames
|
FROM words
|
||||||
);
|
WHERE word IN (${placeholders})`,
|
||||||
|
chineseNames
|
||||||
|
);
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.word && row.english) {
|
if (row.word && row.english) {
|
||||||
result.set(row.word as string, row.english as string);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user