feat: add export settings for Word document generation

- Add export configuration (untranslated text color, page orientation)
- Add second page header option for multi-page exports
- Integrate export settings into main export flow
- Separate export settings page with visual preview
This commit is contained in:
2026-04-12 23:54:00 +08:00
parent a249fb038c
commit 030fce2813
5 changed files with 296 additions and 24 deletions
+8 -4
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getConfig, saveDBConfig, saveAIConfig, type DBConfig, type AIConfig } from "@/lib/config";
import { getConfig, saveDBConfig, saveAIConfig, saveExportConfig, type DBConfig, type AIConfig, type ExportConfig } from "@/lib/config";
// GET /api/config - 获取完整配置
export async function GET() {
@@ -18,11 +18,11 @@ export async function GET() {
}
// POST /api/config - 保存配置
// Body 格式: { db?: DBConfig, ai?: AIConfig }
// Body 格式: { db?: DBConfig, ai?: AIConfig, export?: ExportConfig }
export async function POST(request: Request) {
try {
const body = await request.json();
const { db, ai } = body;
const { db, ai, export: exportConfig } = body;
if (db) {
saveDBConfig(db);
@@ -32,8 +32,12 @@ export async function POST(request: Request) {
saveAIConfig(ai);
}
if (exportConfig) {
saveExportConfig(exportConfig);
}
// 如果同时发送了完整配置
if (!db && !ai && body.db === undefined && body.ai === undefined) {
if (!db && !ai && !exportConfig && body.db === undefined && body.ai === undefined && body.export === undefined) {
// 可能是旧的扁平格式,尝试兼容
if (body.apiKey !== undefined || body.baseUrl !== undefined || body.model !== undefined) {
saveAIConfig({
+40 -8
View File
@@ -1,6 +1,6 @@
"use client";
import {useState, useCallback} from "react";
import {useState, useCallback, useEffect} from "react";
import {
Upload,
Sun,
@@ -116,6 +116,31 @@ export default function RecipeTranslationPage() {
const [data, setData] = useState<WeeklyMenu | null>(null);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string>("");
const [exportConfig, setExportConfig] = useState<{
untranslatedColor: string;
secondPageHeader: boolean;
orientation: "portrait" | "landscape";
}>({
untranslatedColor: "CC0000",
secondPageHeader: false,
orientation: "landscape",
});
// 加载导出配置
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((result) => {
if (result.success && result.data?.export) {
setExportConfig({
untranslatedColor: result.data.export.untranslatedColor || "CC0000",
secondPageHeader: result.data.export.secondPageHeader || false,
orientation: result.data.export.orientation || "landscape",
});
}
})
.catch(console.error);
}, []);
// 收集所有未翻译的菜品名称
const getAllDishNames = useCallback((menu: WeeklyMenu): string[] => {
@@ -195,7 +220,10 @@ export default function RecipeTranslationPage() {
if (!data) return;
try {
// 1. 收集所有需要翻译的内容(星期、餐食类型、菜品)
// 1. 获取导出设置
const { untranslatedColor, secondPageHeader, orientation } = exportConfig;
// 2. 收集所有需要翻译的内容(星期、餐食类型、菜品)
const allNames: string[] = [];
// 收集星期名称(使用实际数据中的星期)
@@ -321,7 +349,7 @@ export default function RecipeTranslationPage() {
} else {
cellContent.push(
new Paragraph({
children: [new TextRun({text: zhText, color: "CC0000"})],
children: [new TextRun({text: zhText, color: untranslatedColor})],
})
);
}
@@ -340,16 +368,20 @@ export default function RecipeTranslationPage() {
tableRows.push(new TableRow({children: dayCells}));
}
// 5. 创建文档 - 横向 A4
// 5. 创建文档 - 根据设置选择页面方向
const isLandscape = orientation === "landscape";
const pageWidth = isLandscape ? 11906 : 16838;
const pageHeight = isLandscape ? 16838 : 11906;
const doc = new Document({
sections: [
{
properties: {
page: {
size: {
orientation: PageOrientation.LANDSCAPE,
width: 11906,
height: 16838,
orientation: isLandscape ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT,
width: pageWidth,
height: pageHeight,
},
margin: {
top: 700, // 约1.27厘米
@@ -395,7 +427,7 @@ export default function RecipeTranslationPage() {
console.error("Export error:", err);
alert("导出失败,请重试");
}
}, [data, fileName]);
}, [data, fileName, exportConfig]);
const handleFileUpload = useCallback(
async (file: File) => {
+182 -12
View File
@@ -1,18 +1,188 @@
import {FileText} from "lucide-react";
"use client";
import { useState, useEffect } from "react";
import { useToast } from "@/components/ui/toast";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ExportConfig {
untranslatedColor: string;
secondPageHeader: boolean;
orientation: "portrait" | "landscape";
}
const defaultExportConfig: ExportConfig = {
untranslatedColor: "CC0000",
secondPageHeader: false,
orientation: "landscape",
};
export default function ExportPage() {
const [config, setConfig] = useState<ExportConfig>(defaultExportConfig);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { addToast } = useToast();
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((result) => {
if (result.success && result.data) {
setConfig({
untranslatedColor: result.data.export?.untranslatedColor || defaultExportConfig.untranslatedColor,
secondPageHeader: result.data.export?.secondPageHeader ?? defaultExportConfig.secondPageHeader,
orientation: result.data.export?.orientation || defaultExportConfig.orientation,
});
}
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ export: config }),
});
const result = await response.json();
if (result.success) {
addToast({ type: "success", title: "导出设置已保存" });
} else {
addToast({ type: "error", title: "保存失败", description: result.error });
}
} catch {
addToast({ type: "error", title: "保存失败" });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> Word </p>
</div>
<div className="rounded-lg border bg-card p-8 text-center">
<FileText className="mx-auto h-12 w-12 text-muted-foreground"/>
<p className="mt-4 text-sm text-muted-foreground">
...
</p>
</div>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> Word </p>
</div>
<div className="h-40 animate-pulse rounded-lg bg-muted" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground"> Word </p>
</div>
{/* 未翻译文本颜色 */}
<div className="space-y-4">
<h3 className="text-sm font-medium"></h3>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="flex items-center gap-3">
<input
type="color"
value={config.untranslatedColor}
onChange={(e) => setConfig({ ...config, untranslatedColor: e.target.value.toUpperCase() })}
className="h-10 w-20 cursor-pointer rounded-md border border-input"
/>
<input
type="text"
value={config.untranslatedColor}
onChange={(e) => {
const val = e.target.value.toUpperCase();
if (/^[0-9A-Fa-f]{0,6}$/.test(val)) {
setConfig({ ...config, untranslatedColor: val });
}
}}
className="w-24 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
placeholder="CC0000"
/>
<span className="text-sm text-muted-foreground">HEX CC0000</span>
</div>
</div>
</div>
{/* 页面设置 */}
<div className="space-y-4">
<h3 className="text-sm font-medium"></h3>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="orientation"
checked={config.orientation === "landscape"}
onChange={() => setConfig({ ...config, orientation: "landscape" })}
className="cursor-pointer"
/>
<span className="text-sm"></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="orientation"
checked={config.orientation === "portrait"}
onChange={() => setConfig({ ...config, orientation: "portrait" })}
className="cursor-pointer"
/>
<span className="text-sm"></span>
</label>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={config.secondPageHeader}
onChange={(e) => setConfig({ ...config, secondPageHeader: e.target.checked })}
className="h-4 w-4 cursor-pointer rounded border-input"
/>
<span className="text-sm"></span>
</label>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
{/* 预览 */}
<div className="space-y-2">
<h3 className="text-sm font-medium"></h3>
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm"></span>
<span
className="rounded px-2 py-1 text-sm"
style={{ color: config.untranslatedColor, border: `1px solid ${config.untranslatedColor}40` }}
>
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm"></span>
<span className="text-sm">{config.orientation === "landscape" ? "横向 (Landscape)" : "纵向 (Portrait)"}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm"></span>
<span className="text-sm">{config.secondPageHeader ? "启用" : "禁用"}</span>
</div>
</div>
</div>
</div>
<div className="flex gap-3">
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
);
}