Compare commits
2 Commits
76ba61d5bb
...
f351bca924
| Author | SHA1 | Date | |
|---|---|---|---|
| f351bca924 | |||
| 6e1d2c6728 |
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* 微信 CDN 图片代理(仅用于本地预览)。
|
||||
* mmbiz.qpic.cn 有防盗链: 带非微信域名 Referer 会返回占位图。
|
||||
* 服务端 fetch 默认不带 Referer, 可拿到真图。仅允许代理微信图片域名。
|
||||
*/
|
||||
const ALLOWED_HOST_RE = /\.qpic\.cn$/;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const u = req.nextUrl.searchParams.get("u");
|
||||
if (!u) return new NextResponse("missing u", { status: 400 });
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(u);
|
||||
} catch {
|
||||
return new NextResponse("bad url", { status: 400 });
|
||||
}
|
||||
if (target.protocol !== "https:" || !ALLOWED_HOST_RE.test(target.hostname)) {
|
||||
return new NextResponse("host not allowed", { status: 403 });
|
||||
}
|
||||
try {
|
||||
const upstream = await fetch(target.toString(), {
|
||||
headers: { "User-Agent": "Mozilla/5.0", Accept: "image/*" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return new NextResponse("upstream error", { status: 502 });
|
||||
}
|
||||
return new NextResponse(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": upstream.headers.get("content-type") ?? "image/jpeg",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse("fetch failed", { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parseDocxToRecipe, fillTemplate, extractTemplateRecipe, type Recipe } from "@/lib/editor135-core";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function loadTemplate(): string {
|
||||
// 优先取项目根目录可替换的外部模板, 其次内置模板
|
||||
const external = path.join(process.cwd(), "template135.html");
|
||||
if (fs.existsSync(external)) return fs.readFileSync(external, "utf8");
|
||||
return fs.readFileSync(path.join(process.cwd(), "public", "template135.html"), "utf8");
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const form = await req.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "请上传文件(表单字段名 file)" }, { status: 400 });
|
||||
}
|
||||
if (!/\.docx$/i.test(file.name)) {
|
||||
return NextResponse.json({ error: "仅支持 .docx 文件" }, { status: 400 });
|
||||
}
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const recipe: Recipe = await parseDocxToRecipe(buf);
|
||||
if (!recipe.days.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "未能从文档中识别出食谱(需要包含 星期一~星期五 与 早餐/早点/午餐/体弱儿餐/午点 等关键词)" },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const template = loadTemplate();
|
||||
const templateRecipe = extractTemplateRecipe(template);
|
||||
const { html, report } = fillTemplate(template, recipe);
|
||||
const totalItems = recipe.days.reduce((n, d) => n + d.meals.reduce((k, m) => k + m.items.length, 0), 0);
|
||||
return NextResponse.json({
|
||||
fileName: file.name,
|
||||
recipe, report, html,
|
||||
summary: {
|
||||
days: recipe.days.length,
|
||||
totalItems,
|
||||
templateDays: templateRecipe.days.length,
|
||||
templateItems: templateRecipe.days.reduce((n, d) => n + d.meals.reduce((k, m) => k + m.items.length, 0), 0),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json({ error: `解析失败: ${msg}` }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------- 类型(与 /api/editor135/render 返回一致) ----------
|
||||
interface RecipeItem { zh: string; en: string }
|
||||
interface RecipeMeal { meal: string; items: RecipeItem[] }
|
||||
interface RecipeDay { day: string; meals: RecipeMeal[] }
|
||||
interface Recipe { days: RecipeDay[] }
|
||||
|
||||
interface RenderResponse {
|
||||
fileName: string;
|
||||
recipe: Recipe;
|
||||
html: string;
|
||||
report: {
|
||||
filled: { day: string; meal: string; replaced: number; added: number; removed: number; oldCount: number; newCount: number }[];
|
||||
warnings: string[];
|
||||
repaired: { day: string; meal: string; text: string }[];
|
||||
mismatch: string[];
|
||||
removedDays: string[];
|
||||
verification: {
|
||||
stylesAdded: number; stylesRemoved: number;
|
||||
imgsUnchanged: boolean; sections: string; elements: string;
|
||||
};
|
||||
};
|
||||
summary: { days: number; totalItems: number; templateDays: number; templateItems: number };
|
||||
}
|
||||
|
||||
const DAY_ORDER = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"];
|
||||
|
||||
export default function Editor135Page() {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RenderResponse | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [tab, setTab] = useState<"source" | "parsed">("source");
|
||||
const [openDays, setOpenDays] = useState<Set<string>>(new Set());
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const upload = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/editor135/render", { method: "POST", body: fd });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(typeof data.error === "string" ? data.error : `请求失败(${res.status})`);
|
||||
} else {
|
||||
const r = data as RenderResponse;
|
||||
setResult(r);
|
||||
setTab("source");
|
||||
// 默认展开第一天
|
||||
const first = [...r.recipe.days].sort(
|
||||
(a, b) => DAY_ORDER.indexOf(a.day) - DAY_ORDER.indexOf(b.day),
|
||||
)[0];
|
||||
setOpenDays(first ? new Set([first.day]) : new Set());
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "网络错误");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) upload(f);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) upload(f);
|
||||
};
|
||||
|
||||
const copyHtml = async () => {
|
||||
if (!result) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.html);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setError("复制失败,请在源代码区手动全选复制");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadHtml = () => {
|
||||
if (!result) return;
|
||||
const blob = new Blob([result.html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (result.fileName?.replace(/\.docx$/i, "") || "recipe") + ".html";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const toggleDay = (day: string) => {
|
||||
setOpenDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(day)) next.delete(day);
|
||||
else next.add(day);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const sortedDays = result
|
||||
? [...result.recipe.days].sort((a, b) => DAY_ORDER.indexOf(a.day) - DAY_ORDER.indexOf(b.day))
|
||||
: [];
|
||||
|
||||
/**
|
||||
* 预览专用: 微信CDN(mmbiz.qpic.cn)防盗链会拦截本地域 Referer 并返回占位图,
|
||||
* 预览时把微信图片重写到本地代理 /api/editor135/img;复制/下载的 HTML 保持原始 URL 不变。
|
||||
*/
|
||||
const toPreviewHtml = (html: string) =>
|
||||
html.replace(/https?:\/\/mmbiz\.qpic\.cn[^"'()\s)]*/g, (m) => `/api/editor135/img?u=${encodeURIComponent(m)}`);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="返回首页"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">135编辑器转换</h1>
|
||||
<p className="text-sm text-muted-foreground">docx 食谱 → 公众号排版 HTML</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
上传 docx 食谱文档,自动填充到 135编辑器 模板样式,输出可直接粘贴回 135编辑器 的 HTML 源代码(只改文本,不改模板样式)
|
||||
</p>
|
||||
|
||||
{/* 错误态 */}
|
||||
{error && (
|
||||
<div className="mb-6 flex items-center gap-2 rounded-xl border border-destructive/50 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<XCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无结果: 居中上传区 */}
|
||||
{(!result || busy) && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => e.key === "Enter" && inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed bg-muted/50 px-6 py-12 text-center transition-all ${
|
||||
dragOver
|
||||
? "border-primary/60 bg-muted"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">正在解析并填充模板…</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">点击选择或拖拽 .docx 食谱文件到此处</p>
|
||||
<p className="text-sm text-muted-foreground">仅支持 .docx 格式</p>
|
||||
</>
|
||||
)}
|
||||
<input ref={inputRef} type="file" accept=".docx" className="hidden" onChange={onPick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果区: 左侧(状态+源代码+解析) 右侧(预览) */}
|
||||
{result && (
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_375px]">
|
||||
{/* ============ 左列 ============ */}
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{/* 紧凑上传区 */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => e.key === "Enter" && inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex cursor-pointer items-center justify-center gap-2 rounded-xl border-2 border-dashed bg-muted/50 px-4 py-4 text-center text-sm transition-all ${
|
||||
dragOver ? "border-primary/60 bg-muted" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">点击或拖拽,重新上传 docx</span>
|
||||
<input ref={inputRef} type="file" accept=".docx" className="hidden" onChange={onPick} />
|
||||
</div>
|
||||
|
||||
{/* 校验状态条 */}
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 pt-4 text-base font-semibold">
|
||||
<FileText className="h-4 w-4" />
|
||||
{result.fileName}
|
||||
{result.report.mismatch.length === 0 ? (
|
||||
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-950 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3 w-3" /> 填充校验通过
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-950 dark:text-red-400">
|
||||
<TriangleAlert className="h-3 w-3" /> 校验有差异
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 px-4 pt-1 text-xs text-muted-foreground">
|
||||
<span>识别 {result.summary.days} 天 · {result.summary.totalItems} 条菜品</span>
|
||||
<span>模板 {result.summary.templateDays} 天 · {result.summary.templateItems} 条</span>
|
||||
<span>
|
||||
样式{" "}
|
||||
{result.report.verification.stylesAdded === 0 && result.report.verification.stylesRemoved === 0 ? (
|
||||
<span className="text-green-600">零变化</span>
|
||||
) : (
|
||||
<span className="text-amber-600">
|
||||
+{result.report.verification.stylesAdded} / -{result.report.verification.stylesRemoved}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
图片{" "}
|
||||
{result.report.verification.imgsUnchanged ? (
|
||||
<span className="text-green-600">未改动</span>
|
||||
) : (
|
||||
<span className="text-red-600">有变化!</span>
|
||||
)}
|
||||
</span>
|
||||
<span>元素 {result.report.verification.elements}</span>
|
||||
{result.report.removedDays.length > 0 && (
|
||||
<span className="font-medium text-amber-600">
|
||||
已按文档移除:{result.report.removedDays.join("、")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 px-4 py-4">
|
||||
<Button onClick={copyHtml} size="sm">
|
||||
{copied ? <CheckCircle2 className="h-4 w-4" data-icon="inline-start" /> : <Copy className="h-4 w-4" data-icon="inline-start" />}
|
||||
{copied ? "已复制到剪贴板" : "复制HTML源代码"}
|
||||
</Button>
|
||||
<Button onClick={downloadHtml} size="sm" variant="outline">
|
||||
<Download className="h-4 w-4" data-icon="inline-start" />
|
||||
下载 .html
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 警示 */}
|
||||
{(result.report.mismatch.length > 0 || result.report.warnings.length > 0) && (
|
||||
<div className="rounded-xl border border-amber-500/50 bg-amber-500/5 px-4 py-3 text-xs text-amber-700 dark:text-amber-400">
|
||||
{result.report.mismatch.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="font-semibold">填充差异(请检查):</p>
|
||||
<ul className="mt-1 list-disc pl-4">
|
||||
{result.report.mismatch.slice(0, 10).map((m, i) => <li key={i}>{m}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{result.report.warnings.length > 0 && (
|
||||
<div>
|
||||
<p className="font-semibold">提示:</p>
|
||||
<ul className="mt-1 list-disc pl-4">
|
||||
{result.report.warnings.slice(0, 10).map((w, i) => <li key={i}>{w}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 左列 Tabs */}
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="grid grid-cols-2 border-b">
|
||||
{(["source", "parsed"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`py-2.5 text-sm font-medium transition-colors ${
|
||||
tab === t
|
||||
? "border-b-2 border-primary text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t === "source" ? "源代码" : "解析结果"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 源代码: 自动换行完整展示 */}
|
||||
{tab === "source" && (
|
||||
<div className="p-4">
|
||||
<pre className="max-h-[70vh] overflow-auto rounded-lg bg-zinc-950 p-4 text-[11px] leading-relaxed text-zinc-100 [white-space:pre-wrap] [word-break:break-all]">
|
||||
<code>{result.html}</code>
|
||||
</pre>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
共 {(result.html.length / 1024).toFixed(1)} KB · 全文已换行展示,可直接全选 · 复制后粘贴到 135编辑器「HTML源码」模式
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 解析结果: 按星期折叠面板 */}
|
||||
{tab === "parsed" && (
|
||||
<div className="p-2">
|
||||
{sortedDays.map((d) => {
|
||||
const open = openDays.has(d.day);
|
||||
const total = d.meals.reduce((n, m) => n + m.items.length, 0);
|
||||
return (
|
||||
<div key={d.day} className="border-b last:border-b-0">
|
||||
<button
|
||||
onClick={() => toggleDay(d.day)}
|
||||
className="flex w-full items-center justify-between px-3 py-3 text-left text-sm font-medium hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{d.day}
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
|
||||
{total} 条
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-col gap-3 px-3 pb-4">
|
||||
{d.meals.map((m) => (
|
||||
<div key={m.meal}>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
|
||||
{m.meal}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{m.items.length} 条</span>
|
||||
</div>
|
||||
<div className="rounded-lg border">
|
||||
{m.items.map((it, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-baseline justify-between gap-3 px-3 py-1.5 text-sm ${
|
||||
i > 0 ? "border-t" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{it.zh || "—"}</span>
|
||||
<span className="text-right text-xs text-muted-foreground">{it.en}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 归一化修复记录 */}
|
||||
{result.report.repaired.length > 0 && (
|
||||
<div className="rounded-xl border bg-card px-4 py-3 text-xs text-muted-foreground">
|
||||
<p className="mb-1 font-medium text-foreground">归一化修复(模板裸文本菜名)</p>
|
||||
<ul className="list-disc pl-4">
|
||||
{result.report.repaired.map((r, i) => (
|
||||
<li key={i}>{r.day} {r.meal}:{r.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ============ 右列: 预览(固定宽度, 内部滚动, 吸顶) ============ */}
|
||||
<div className="lg:sticky lg:top-6">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<iframe
|
||||
title="食谱预览"
|
||||
srcDoc={`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body style="margin:0;">${toPreviewHtml(result.html)}</body></html>`}
|
||||
style={{ height: "min(78vh, 900px)" }}
|
||||
className="mx-auto block w-full max-w-[375px] rounded-lg border bg-white"
|
||||
/>
|
||||
<p className="mt-2 text-center text-xs text-muted-foreground">
|
||||
预览内可上下滚动(375px),与公众号阅读宽度一致
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-5
@@ -100,11 +100,19 @@ function HarderCompose() {
|
||||
<p className="text-sm text-muted-foreground">Word 文档解析 · 智能翻译</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4"/>
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/editor135">
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<Cookie className="h-4 w-4"/>
|
||||
135编辑器转换
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4"/>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* recipe-core.ts — 尚城幼儿园食谱转换核心库
|
||||
*
|
||||
* 职责:
|
||||
* 1. parseDocxToRecipe: docx(mammoth) → 结构化食谱 JSON(支持常规宽表/转置宽表/长表/分段文本)
|
||||
* 2. fillTemplate: 食谱 JSON + 135编辑器模板HTML → 按原样式填充后的完整 HTML
|
||||
* (只改文本,不改任何 style/图片/结构;条目不足克隆模板条目,多余删除)
|
||||
*
|
||||
* 逻辑移植自已验证的 tools/parse_recipe.mjs 与 tools/fill_135editor.mjs。
|
||||
*/
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
// ---------- 类型 ----------
|
||||
export interface RecipeItem { zh: string; en: string }
|
||||
export interface RecipeMeal { meal: string; items: RecipeItem[] }
|
||||
export interface RecipeDay { day: string; meals: RecipeMeal[] }
|
||||
export interface Recipe { days: RecipeDay[] }
|
||||
|
||||
export interface MealFillInfo {
|
||||
day: string; meal: string;
|
||||
replaced: number; added: number; removed: number;
|
||||
oldCount: number; newCount: number;
|
||||
}
|
||||
export interface FillReport {
|
||||
filled: MealFillInfo[];
|
||||
warnings: string[];
|
||||
repaired: { day: string; meal: string; text: string }[];
|
||||
mismatch: string[];
|
||||
/** 模板中存在、但 docx 中没有的星期 → 已从输出中整段移除 */
|
||||
removedDays: string[];
|
||||
verification: {
|
||||
stylesAdded: number; stylesRemoved: number;
|
||||
styleSampleAdded: [string, number][]; styleSampleRemoved: [string, number][];
|
||||
imgsUnchanged: boolean; sections: string; elements: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- docx 解析 ----------
|
||||
const DAY_RE = /^星期[一二三四五六日]$/;
|
||||
const DAY_ALIASES: Record<string, string> = {
|
||||
周一: "星期一", 周二: "星期二", 周三: "星期三", 周四: "星期四",
|
||||
周五: "星期五", 周六: "星期六", 周日: "星期日", 周天: "星期日",
|
||||
礼拜一: "星期一", 礼拜二: "星期二", 礼拜三: "星期三", 礼拜四: "星期四",
|
||||
礼拜五: "星期五", 礼拜六: "星期六", 礼拜日: "星期日", 礼拜天: "星期日",
|
||||
};
|
||||
const MEAL_ALIASES: Record<string, string> = {
|
||||
早餐: "早餐", 早点: "早点", 水果: "早点", 果点: "早点",
|
||||
午餐: "午餐", 中餐: "午餐", 午饭: "午餐",
|
||||
体弱儿餐: "体弱儿餐", 体弱儿: "体弱儿餐", 特殊餐: "体弱儿餐",
|
||||
午点: "午点", 午睡后: "午点", 点心: "午点", 下午点心: "午点", 下午茶: "午点",
|
||||
晚餐: "晚餐", 晚点: "晚点",
|
||||
};
|
||||
const SEP_RE = /[;;\n、]+/;
|
||||
const CJK_RE = /[\u4e00-\u9fff]/;
|
||||
const LATIN_RE = /[A-Za-z]/;
|
||||
|
||||
const norm = (s: unknown) => String(s ?? "").replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
|
||||
const normKey = (s: unknown) => String(s ?? "").replace(/\s+/g, "");
|
||||
|
||||
function canonDay(s: unknown): string | null {
|
||||
const t = normKey(s);
|
||||
const m = t.match(/星期[一二三四五六日]/);
|
||||
if (m) return m[0];
|
||||
if (DAY_RE.test(t)) return t;
|
||||
const w = t.match(/周[一二三四五六日天]/);
|
||||
if (w) return w[0] === "周天" ? "星期日" : w[0].replace("周", "星期");
|
||||
return DAY_ALIASES[t] ?? null;
|
||||
}
|
||||
function canonMeal(s: unknown): string | null {
|
||||
const t = normKey(s);
|
||||
if (MEAL_ALIASES[t]) return MEAL_ALIASES[t];
|
||||
// 关键词按长短优先, 避免 "早点/水果餐" 被 "早" 误判为早餐
|
||||
if (t.includes("体弱")) return "体弱儿餐";
|
||||
if (t.includes("早点") || t.includes("水果")) return "早点";
|
||||
if (t.includes("早餐")) return "早餐";
|
||||
if (t.includes("午点") || t.includes("点心")) return "午点";
|
||||
if (t.includes("午餐") || t.includes("中餐")) return "午餐";
|
||||
if (t.includes("晚点")) return "晚点";
|
||||
if (t.includes("晚餐")) return "晚餐";
|
||||
if (t.includes("早")) return "早餐";
|
||||
if (t.includes("午")) return "午餐";
|
||||
if (t.includes("晚")) return "晚餐";
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureDay(rec: Recipe, day: string): RecipeDay {
|
||||
let d = rec.days.find((x) => x.day === day);
|
||||
if (!d) { d = { day, meals: [] }; rec.days.push(d); }
|
||||
return d;
|
||||
}
|
||||
function ensureMeal(d: RecipeDay, meal: string): RecipeMeal {
|
||||
let m = d.meals.find((x) => x.meal === meal);
|
||||
if (!m) { m = { meal, items: [] }; d.meals.push(m); }
|
||||
return m;
|
||||
}
|
||||
function addItem(m: RecipeMeal, zh: string, en: string) {
|
||||
const z = norm(zh), e = norm(en);
|
||||
if (!z && !e) return;
|
||||
m.items.push({ zh: z, en: e });
|
||||
}
|
||||
/** 格子→菜品列表(中英交替行: 中文行=菜名, 紧跟英文行=其英文名)。必须接收原始文本(含换行)。 */
|
||||
function splitCell(cell: string): { zhList: string[]; enList: string[] } {
|
||||
const raw = String(cell ?? "").replace(/\r/g, "");
|
||||
const parts = raw.split(SEP_RE).map(norm).filter(Boolean);
|
||||
const items: { zh: string; en: string }[] = [];
|
||||
for (const p of parts) {
|
||||
if (CJK_RE.test(p)) items.push({ zh: p, en: "" });
|
||||
else if (LATIN_RE.test(p)) {
|
||||
if (items.length) items[items.length - 1].en = p;
|
||||
else items.push({ zh: "", en: p });
|
||||
} else items.push({ zh: p, en: "" });
|
||||
}
|
||||
if (!items.some((x) => x.zh)) return { zhList: [], enList: items.map((x) => x.en) };
|
||||
return { zhList: items.map((x) => x.zh).filter(Boolean), enList: items.map((x) => x.en).filter(Boolean) };
|
||||
}
|
||||
|
||||
/** B1 常规宽表: 表头含≥2个星期列, 第一列是餐次 */
|
||||
function parseWideB1(rows: string[][]): Recipe | null {
|
||||
const rec: Recipe = { days: [] };
|
||||
let header: string[] | null = null;
|
||||
let dayCols: (string | null)[] = [];
|
||||
for (const row of rows) {
|
||||
const days = row.map(normKey).map(canonDay);
|
||||
if (days.filter(Boolean).length >= 2) { header = row; dayCols = days; break; }
|
||||
}
|
||||
if (!header) return null;
|
||||
let curMeal: string | null = null;
|
||||
for (const row of rows) {
|
||||
const mealKey = canonMeal(row[0] ?? "");
|
||||
if (mealKey) curMeal = mealKey;
|
||||
if (!curMeal) continue;
|
||||
for (let ci = 0; ci < header.length; ci++) {
|
||||
const day = dayCols[ci];
|
||||
if (!day || !(row[ci] ?? "").trim()) continue;
|
||||
const { zhList, enList } = splitCell(row[ci]);
|
||||
if (!zhList.length && !enList.length) continue;
|
||||
const m = ensureMeal(ensureDay(rec, day), curMeal);
|
||||
const n = Math.max(zhList.length, enList.length);
|
||||
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
|
||||
}
|
||||
}
|
||||
return rec.days.length ? rec : null;
|
||||
}
|
||||
|
||||
/** B2 转置宽表: 星期在第一列, 餐次在表头行(用户真实文件即此格式) */
|
||||
function parseWideB2(rows: string[][]): Recipe | null {
|
||||
const rec: Recipe = { days: [] };
|
||||
for (let ri = 0; ri < rows.length; ri++) {
|
||||
const cells = rows[ri].map(normKey);
|
||||
const mealCols = cells
|
||||
.map((c, i) => (canonMeal(c) && i > 0 ? { meal: canonMeal(c)!, col: i } : null))
|
||||
.filter((x): x is { meal: string; col: number } => !!x);
|
||||
if (mealCols.length < 2) continue;
|
||||
for (let rj = ri + 1; rj < rows.length; rj++) {
|
||||
const day = canonDay(rows[rj][0] ?? "");
|
||||
if (!day) continue;
|
||||
for (const { meal, col } of mealCols) {
|
||||
if (!(rows[rj][col] ?? "").trim()) continue;
|
||||
const { zhList, enList } = splitCell(rows[rj][col]);
|
||||
if (!zhList.length && !enList.length) continue;
|
||||
const m = ensureMeal(ensureDay(rec, day), meal);
|
||||
const n = Math.max(zhList.length, enList.length);
|
||||
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
|
||||
}
|
||||
}
|
||||
if (rec.days.length) return rec;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 长表: 列 = 星期|餐次|菜品|英文, 空星期/餐次沿用上一行(兼容合并单元格) */
|
||||
function parseLongRows(rows: string[][]): Recipe {
|
||||
const rec: Recipe = { days: [] };
|
||||
let curDay: string | null = null, curMeal: string | null = null;
|
||||
for (const row of rows) {
|
||||
if (!row.some((c) => norm(c))) continue;
|
||||
let dayIdx = -1, mealIdx = -1;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
if (canonDay(row[i]) && dayIdx < 0) dayIdx = i;
|
||||
if (canonMeal(row[i]) && mealIdx < 0 && i !== dayIdx) mealIdx = i;
|
||||
}
|
||||
if (dayIdx >= 0) curDay = canonDay(row[dayIdx]);
|
||||
if (mealIdx >= 0) curMeal = canonMeal(row[mealIdx]);
|
||||
if (!curDay || !curMeal) continue;
|
||||
const zhCells: string[] = [], enCells: string[] = [];
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
if (i === dayIdx || i === mealIdx || !norm(row[i])) continue;
|
||||
(CJK_RE.test(row[i]) ? zhCells : enCells).push(row[i]);
|
||||
}
|
||||
const m = ensureMeal(ensureDay(rec, curDay), curMeal);
|
||||
const zhParts = zhCells.flatMap((c) => splitCell(c).zhList);
|
||||
let enParts = enCells.map(norm).filter(Boolean);
|
||||
if (!enParts.length) enParts = zhCells.flatMap((c) => splitCell(c).enList);
|
||||
const n = Math.max(zhParts.length, enParts.length);
|
||||
for (let i = 0; i < n; i++) addItem(m, zhParts[i] ?? "", enParts[i] ?? "");
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** 分段文本: 【星期一】/ 早餐:菜;菜 */
|
||||
function parseTextLines(lines: string[]): Recipe {
|
||||
const rec: Recipe = { days: [] };
|
||||
let curDay: string | null = null, curMeal: string | null = null;
|
||||
for (const raw0 of lines) {
|
||||
const raw = raw0.replace(/\r/g, "").trim();
|
||||
if (!raw) continue;
|
||||
const dayM = raw.match(/^[【[]?\s*(星期[一二三四五六日])\s*[】\]]?$/);
|
||||
if (dayM) { curDay = dayM[1]; curMeal = null; continue; }
|
||||
const mealM = raw.match(/^(早餐|早点|水果|午餐|中餐|体弱儿餐|午点|点心|晚餐|晚点)\s*[::]\s*(.*)$/);
|
||||
if (mealM) {
|
||||
curMeal = canonMeal(mealM[1]);
|
||||
if (curDay && curMeal) {
|
||||
const m = ensureMeal(ensureDay(rec, curDay), curMeal);
|
||||
const { zhList, enList } = splitCell(mealM[2]);
|
||||
const n = Math.max(zhList.length, enList.length);
|
||||
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (curDay && curMeal && (CJK_RE.test(raw) || LATIN_RE.test(raw))) {
|
||||
const m = ensureMeal(ensureDay(rec, curDay), curMeal);
|
||||
const { zhList, enList } = splitCell(raw);
|
||||
const n = Math.max(zhList.length, enList.length);
|
||||
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
|
||||
}
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** mammoth HTML → 表格行(单元格内多个<p>按行还原) */
|
||||
function htmlToRows(html: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
const tableRe = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
|
||||
let mt: RegExpExecArray | null;
|
||||
while ((mt = tableRe.exec(html))) {
|
||||
const cells = [...mt[1].matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g)].map((c) =>
|
||||
(c[1] ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " "),
|
||||
);
|
||||
rows.push(cells);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function parseDocxToRecipe(buf: Buffer): Promise<Recipe> {
|
||||
const mammoth = await import("mammoth");
|
||||
const { value: html } = await mammoth.convertToHtml({ buffer: buf });
|
||||
const rows = htmlToRows(html);
|
||||
if (rows.length) {
|
||||
const rec = parseWideB1(rows) ?? parseWideB2(rows) ?? parseLongRows(rows);
|
||||
if (rec.days.length) return rec;
|
||||
}
|
||||
return parseTextLines(html.replace(/<[^>]+>/g, "\n").split("\n"));
|
||||
}
|
||||
|
||||
// ---------- 模板填充(逻辑与 fill_135editor.mjs 页面脚本一致, DOM 换成 jsdom) ----------
|
||||
const MEAL_KEY: Record<string, string> = {
|
||||
早餐: "breakfast", 早点: "fruit", 水果: "fruit",
|
||||
午餐: "lunch", 中餐: "lunch",
|
||||
体弱儿餐: "special",
|
||||
午点: "snack", 点心: "snack",
|
||||
晚餐: "dinner", 晚点: "dinner",
|
||||
};
|
||||
const MEAL_KEY_TO_LABEL: Record<string, string> = {
|
||||
breakfast: "早餐", fruit: "早点", lunch: "午餐", special: "体弱儿餐", snack: "午点", dinner: "晚餐",
|
||||
};
|
||||
const DAY_SET = new Set(["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]);
|
||||
|
||||
interface El {
|
||||
tagName: string;
|
||||
children: HTMLElement[];
|
||||
childNodes: ChildNode[];
|
||||
textContent: string;
|
||||
parentElement: HTMLElement | null;
|
||||
nextElementSibling: HTMLElement | null;
|
||||
getAttribute(n: string): string | null;
|
||||
cloneNode(deep: boolean): any;
|
||||
querySelectorAll(s: string): any[];
|
||||
remove(): void;
|
||||
insertBefore(n: Node, r: Node | null): Node;
|
||||
parentNode: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
zhEl: HTMLElement; zhCont: HTMLElement[];
|
||||
enEl: HTMLElement | null; enCont: HTMLElement[];
|
||||
zhBlock: HTMLElement | null; enBlock: HTMLElement | null;
|
||||
ca: HTMLElement | null; valid: boolean;
|
||||
}
|
||||
interface MealCol { key: string; items: Item[] }
|
||||
interface DayCol { day: string; meals: MealCol[]; titleEl: HTMLElement | null }
|
||||
|
||||
const leafText = (el: HTMLElement) => (el.textContent ?? "").replace(/\u00a0/g, " ").trim();
|
||||
|
||||
function collect(body: HTMLElement): DayCol[] {
|
||||
const days: DayCol[] = [];
|
||||
let curDay: DayCol | null = null, curMeal: MealCol | null = null, curItem: Item | null = null, lastKind: string | null = null;
|
||||
const getDay = (t: string, el?: HTMLElement) => { let d = days.find((x) => x.day === t); if (!d) { d = { day: t, meals: [], titleEl: el ?? null }; days.push(d); } if (el && !d.titleEl) d.titleEl = el; return d; };
|
||||
const getMeal = (d: DayCol, key: string) => { let m = d.meals.find((x) => x.key === key); if (!m) { m = { key, items: [] }; d.meals.push(m); } return m; };
|
||||
|
||||
for (const el of body.querySelectorAll("*") as unknown as HTMLElement[]) {
|
||||
if (el.children.length) continue;
|
||||
const t = leafText(el);
|
||||
if (!t) continue;
|
||||
if (DAY_SET.has(t)) { curDay = getDay(t, el); curMeal = null; curItem = null; lastKind = null; continue; }
|
||||
const mk = MEAL_KEY[t];
|
||||
if (mk) {
|
||||
if (!curDay) continue;
|
||||
curMeal = getMeal(curDay, mk); curItem = null; lastKind = null; continue;
|
||||
}
|
||||
if (!curDay || !curMeal) continue;
|
||||
if (CJK_RE.test(t)) {
|
||||
if (lastKind === "zh" && curItem) curItem.zhCont.push(el);
|
||||
else { curItem = { zhEl: el, zhCont: [], enEl: null, enCont: [], zhBlock: null, enBlock: null, ca: null, valid: false }; curMeal.items.push(curItem); lastKind = "zh"; }
|
||||
} else if (LATIN_RE.test(t)) {
|
||||
if (curItem && lastKind === "zh" && !curItem.enEl) { curItem.enEl = el; lastKind = "en"; }
|
||||
else if (curItem && lastKind === "en") curItem.enCont.push(el);
|
||||
}
|
||||
}
|
||||
// 配对: 中英块的最近公共祖先下必须是相邻兄弟
|
||||
const pathTo = (el: HTMLElement): HTMLElement[] => { const p: HTMLElement[] = []; let n: HTMLElement | null = el; while (n && n !== body) { p.unshift(n); n = n.parentElement; } return p; };
|
||||
for (const d of days) for (const m of d.meals) for (const it of m.items) {
|
||||
it.valid = false;
|
||||
if (!it.enEl) continue;
|
||||
const zp = pathTo(it.zhEl), ep = pathTo(it.enEl);
|
||||
let ca: HTMLElement | null = null;
|
||||
for (let i = 0; i < Math.min(zp.length, ep.length); i++) { if (zp[i] === ep[i]) ca = zp[i]; else break; }
|
||||
if (!ca) continue;
|
||||
const zb = zp[zp.indexOf(ca) + 1], eb = ep[ep.indexOf(ca) + 1];
|
||||
if (!zb || !eb) continue;
|
||||
if (zb.nextElementSibling !== eb) continue;
|
||||
it.zhBlock = zb; it.enBlock = eb; it.ca = ca; it.valid = true;
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function daysToRecipe(days: DayCol[]): Recipe {
|
||||
return {
|
||||
days: days.map((d) => ({
|
||||
day: d.day,
|
||||
meals: d.meals.map((m) => ({
|
||||
meal: MEAL_KEY_TO_LABEL[m.key],
|
||||
items: m.items.filter((it) => it.valid).map((it) => ({
|
||||
zh: [it.zhEl, ...it.zhCont].map(leafText).join(""),
|
||||
en: it.enEl && it.enCont.length ? [it.enEl, ...it.enCont].map(leafText).join("") : (it.enEl ? leafText(it.enEl) : ""),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
interface Snapshot { styles: Map<string, number>; imgs: string[]; sections: number; elements: number }
|
||||
function snapshot(body: HTMLElement): Snapshot {
|
||||
const styles = new Map<string, number>();
|
||||
let sections = 0, elements = 0;
|
||||
for (const el of body.querySelectorAll("*") as unknown as HTMLElement[]) {
|
||||
elements++;
|
||||
if (el.tagName === "SECTION") sections++;
|
||||
const st = el.getAttribute("style") || "";
|
||||
if (st) styles.set(st, (styles.get(st) ?? 0) + 1);
|
||||
}
|
||||
const imgs = [...body.querySelectorAll("img")].map((im: any) => im.getAttribute("src") || "");
|
||||
return { styles, imgs, sections, elements };
|
||||
}
|
||||
function diffSnapshot(a: Snapshot, b: Snapshot) {
|
||||
const extras: [string, number][] = [], missing: [string, number][] = [];
|
||||
for (const [k, v] of b.styles) { const dv = v - (a.styles.get(k) ?? 0); if (dv > 0) extras.push([k, dv]); }
|
||||
for (const [k, v] of a.styles) { const dv = (b.styles.get(k) ?? 0) - v; if (dv > 0) missing.push([k, dv]); }
|
||||
const ia = [...a.imgs].sort().join("|"), ib = [...b.imgs].sort().join("|");
|
||||
return {
|
||||
stylesAdded: extras.length, stylesRemoved: missing.length,
|
||||
styleSampleAdded: extras.slice(0, 3), styleSampleRemoved: missing.slice(0, 3),
|
||||
imgsUnchanged: ia === ib,
|
||||
sections: `${a.sections} -> ${b.sections}`, elements: `${a.elements} -> ${b.elements}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 块内文本统一设置: 兼容 元素叶子 / 块直接文本节点 / 空块;不触碰style与结构 */
|
||||
function setBlockText(block: HTMLElement, txt: string, wantCJK: boolean) {
|
||||
for (const n of [...block.childNodes]) if (n.nodeType === 3) n.textContent = "";
|
||||
const leaves = [...block.querySelectorAll("*")].filter((el: any) => el.children.length === 0) as HTMLElement[];
|
||||
if (leaves.length) {
|
||||
const main = leaves.find((el) => (wantCJK ? CJK_RE.test(leafText(el)) : LATIN_RE.test(leafText(el)))) ?? leaves[0];
|
||||
for (const el of leaves) if (el !== main) (el as HTMLElement).textContent = "";
|
||||
main.textContent = txt;
|
||||
return;
|
||||
}
|
||||
const dts = [...block.childNodes].filter((n) => n.nodeType === 3);
|
||||
if (dts.length) { dts[0].textContent = txt; return; }
|
||||
block.textContent = txt;
|
||||
}
|
||||
|
||||
/** 归一化修复: 列表容器里的裸文本菜名包进模板自身样式块(视觉不变) */
|
||||
function normalizeBareText(days: DayCol[]): { day: string; meal: string; text: string }[] {
|
||||
const repaired: { day: string; meal: string; text: string }[] = [];
|
||||
for (const d of days) for (const m of d.meals) {
|
||||
const valid = m.items.filter((it) => it.valid);
|
||||
if (!valid.length) continue;
|
||||
const model = valid[valid.length - 1];
|
||||
const containers = new Set(valid.map((it) => it.ca).filter(Boolean) as HTMLElement[]);
|
||||
for (const ca of containers) {
|
||||
for (const node of [...ca.childNodes]) {
|
||||
if (node.nodeType !== 3) continue;
|
||||
const t = (node.textContent ?? "").replace(/\u00a0/g, " ").trim();
|
||||
if (!t || t.length > 60) continue;
|
||||
const isZh = CJK_RE.test(t), isEn = LATIN_RE.test(t);
|
||||
if (!isZh && !isEn) continue;
|
||||
if (isZh && model.zhBlock && model.enBlock) {
|
||||
const zc = model.zhBlock.cloneNode(true) as HTMLElement;
|
||||
const ec = model.enBlock.cloneNode(true) as HTMLElement;
|
||||
setBlockText(zc, t, true);
|
||||
setBlockText(ec, "", false);
|
||||
ca.insertBefore(zc, node);
|
||||
(zc.parentNode as HTMLElement).insertBefore(ec, zc.nextSibling);
|
||||
} else if (model.enBlock) {
|
||||
const ec = model.enBlock.cloneNode(true) as HTMLElement;
|
||||
setBlockText(ec, t, false);
|
||||
ca.insertBefore(ec, node);
|
||||
}
|
||||
ca.removeChild(node);
|
||||
repaired.push({ day: d.day, meal: MEAL_KEY_TO_LABEL[m.key], text: t });
|
||||
}
|
||||
}
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除模板中存在、但 docx 食谱里没有的"天"区块(标题块+全部餐次块,含中间隔断)。
|
||||
* 算法: 对相邻两天标题叶子求最近公共祖先(LCA),在该层级删除 [当天子块, 下一天子块) 区间;
|
||||
* 对最后一个模板天, 以该天最后一个有效条目的英文块为终点(含端点)删除。
|
||||
* 只删区块节点, 不触碰任何保留内容的 style 与结构。
|
||||
*/
|
||||
function removeMissingDays(body: HTMLElement, dayCols: DayCol[], recipe: Recipe): string[] {
|
||||
const wanted = new Set((recipe.days ?? []).map((d) => d.day));
|
||||
const removed: string[] = [];
|
||||
const leaves = Array.from(body.querySelectorAll<HTMLElement>("*")).filter(
|
||||
(el) => el.children.length === 0 && el.textContent?.trim(),
|
||||
);
|
||||
|
||||
/**
|
||||
* 逐节点精确删除文档序区间 [a, b) (includeB 时含 b)。
|
||||
* 规则: 节点属于 a 的祖先链时不可整体删除(内部可能含 a 之前需保留的内容, 如相邻天共用容器),
|
||||
* 只前进到其 nextSibling(必然位于 a 之后, 可删)或爬升;
|
||||
* 其余节点整体位于 a 之后, 直接删除, 无 nextSibling 时爬升(父节点按祖先链判断再决定);
|
||||
* 前进进入的节点若包含 b 则下钻。保证不误删保留天。
|
||||
*/
|
||||
const removeBetween = (a: HTMLElement, b: HTMLElement | null, includeB: boolean) => {
|
||||
if (!a || !a.parentNode || (b && a.contains(b))) return;
|
||||
const ancestors = new Set<Node>();
|
||||
for (let n: Node | null = a.parentElement; n; n = n.parentElement) ancestors.add(n);
|
||||
let cur: Node | null = a;
|
||||
let guard = 0;
|
||||
while (cur && guard++ < 500000) {
|
||||
if (cur === body) return;
|
||||
if (b && cur === b) {
|
||||
if (includeB) cur.parentNode?.removeChild(cur);
|
||||
return;
|
||||
}
|
||||
const el = cur as HTMLElement;
|
||||
const isAncestor = ancestors.has(cur);
|
||||
if (!isAncestor && b && typeof el.contains === "function" && el.contains(b)) {
|
||||
cur = el.firstChild; // 整体位于 a 之后且含 b: 下钻
|
||||
continue;
|
||||
}
|
||||
if (isAncestor) {
|
||||
cur = cur.nextSibling ?? cur.parentNode; // 祖先不删: 前进(在 a 之后)或爬升
|
||||
continue;
|
||||
}
|
||||
const nx = cur.nextSibling;
|
||||
const parent = cur.parentNode;
|
||||
cur.parentNode?.removeChild(cur);
|
||||
cur = nx ?? parent;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < dayCols.length; i++) {
|
||||
const d = dayCols[i];
|
||||
if (wanted.has(d.day)) continue;
|
||||
const titleEl = d.titleEl;
|
||||
if (!titleEl || !body.contains(titleEl)) continue;
|
||||
|
||||
// 区间终点: 优先用下一个模板天的标题(排除); 最后一个天用尾部标记(排除), 兜底用最后条目的英文块(含端点)
|
||||
const nextTitle = dayCols[i + 1]?.titleEl ?? null;
|
||||
let endLeaf: HTMLElement | null = nextTitle && nextTitle !== titleEl && body.contains(nextTitle) ? nextTitle : null;
|
||||
let includeEnd = false;
|
||||
if (!endLeaf) {
|
||||
const tail = leaves.find((el) => {
|
||||
const t = el.textContent!.trim();
|
||||
return (t === "三审三校" || t.startsWith("温馨提示")) && el !== titleEl && !titleEl.contains(el);
|
||||
});
|
||||
if (tail && body.contains(tail)) {
|
||||
endLeaf = tail;
|
||||
includeEnd = false;
|
||||
} else {
|
||||
const items = d.meals.flatMap((m) => m.items.filter((it) => it.valid));
|
||||
endLeaf = items.length ? (items[items.length - 1].enBlock ?? null) : null;
|
||||
includeEnd = !!endLeaf;
|
||||
}
|
||||
}
|
||||
removeBetween(titleEl, endLeaf, includeEnd);
|
||||
removed.push(d.day);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function fillTemplate(templateHtml: string, recipe: Recipe): { html: string; report: FillReport } {
|
||||
const dom = new JSDOM(`<html><body>${templateHtml}</body></html>`, { url: "https://www.135editor.com/" });
|
||||
const body = dom.window.document.body as unknown as HTMLElement;
|
||||
|
||||
const days = collect(body);
|
||||
const repaired = normalizeBareText(days);
|
||||
const dayCols = repaired.length ? collect(body) : days;
|
||||
// 先移除 docx 中缺失的星期(如周五缺席则整段删除周五), 再重新收集(删除后节点引用会失效)并做基线快照与填充
|
||||
const removedDays = removeMissingDays(body, dayCols, recipe);
|
||||
const templateDayNames = new Set(dayCols.map((d) => d.day));
|
||||
const fillCols = removedDays.length ? collect(body) : dayCols;
|
||||
const baseline = snapshot(body);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const filled: MealFillInfo[] = [];
|
||||
const joinLeaves = (els: HTMLElement[]) => els.map(leafText).join("");
|
||||
|
||||
for (const rd of recipe.days ?? []) {
|
||||
const d = fillCols.find((x) => x.day === rd.day);
|
||||
if (!d) { warnings.push(`编辑器模板中找不到 ${rd.day}`); continue; }
|
||||
for (const rm of rd.meals ?? []) {
|
||||
const mk = MEAL_KEY[rm.meal];
|
||||
if (!mk) { warnings.push(`未知餐次: ${rm.meal}`); continue; }
|
||||
const m = d.meals.find((x) => x.key === mk);
|
||||
if (!m) { warnings.push(`${rd.day} 模板中无 ${rm.meal}`); continue; }
|
||||
const oldItems = m.items.filter((it) => it.valid);
|
||||
if (!oldItems.length) { warnings.push(`${rd.day} ${rm.meal} 模板无可克隆条目`); continue; }
|
||||
const newItems = rm.items ?? [];
|
||||
let added = 0, removed = 0, replaced = 0;
|
||||
// 1) 就地替换(文本未变不碰叶子, 保持字节分布)
|
||||
for (let i = 0; i < Math.min(oldItems.length, newItems.length); i++) {
|
||||
const it = oldItems[i], ni = newItems[i];
|
||||
if (joinLeaves([it.zhEl, ...it.zhCont]) !== (ni.zh ?? "")) {
|
||||
it.zhEl.textContent = ni.zh ?? "";
|
||||
for (const el of it.zhCont) el.textContent = "";
|
||||
}
|
||||
const enNow = it.enEl ? joinLeaves([it.enEl, ...it.enCont]) : "";
|
||||
if (enNow !== (ni.en ?? "")) {
|
||||
if (it.enEl) it.enEl.textContent = ni.en ?? "";
|
||||
for (const el of it.enCont) el.textContent = "";
|
||||
}
|
||||
replaced++;
|
||||
}
|
||||
// 2) 锚点递进克隆补足
|
||||
const model = oldItems[oldItems.length - 1];
|
||||
let anchorEn = model.enBlock!;
|
||||
for (let i = oldItems.length; i < newItems.length; i++) {
|
||||
const zc = model.zhBlock!.cloneNode(true) as HTMLElement;
|
||||
const ec = model.enBlock!.cloneNode(true) as HTMLElement;
|
||||
const parent = anchorEn.parentNode!;
|
||||
parent.insertBefore(zc, anchorEn.nextSibling);
|
||||
parent.insertBefore(ec, zc.nextSibling);
|
||||
setBlockText(zc, newItems[i].zh ?? "", true);
|
||||
setBlockText(ec, newItems[i].en ?? "", false);
|
||||
anchorEn = ec;
|
||||
added++;
|
||||
}
|
||||
// 3) 从后往前删除多余
|
||||
for (let i = oldItems.length - 1; i >= newItems.length; i--) {
|
||||
const it = oldItems[i];
|
||||
it.zhBlock?.remove();
|
||||
it.enBlock?.remove();
|
||||
for (const el of [...it.zhCont, ...it.enCont]) el.textContent = "";
|
||||
removed++;
|
||||
}
|
||||
filled.push({ day: rd.day, meal: rm.meal, replaced, added, removed, oldCount: oldItems.length, newCount: newItems.length });
|
||||
}
|
||||
}
|
||||
|
||||
// 校验: 重新收集逐项比对
|
||||
const after = snapshot(body);
|
||||
const recipe2 = daysToRecipe(collect(body));
|
||||
const mismatch: string[] = [];
|
||||
for (const rd of recipe.days ?? []) {
|
||||
if (!templateDayNames.has(rd.day)) continue; // 模板本来就没有该天(如周末), 已在 warnings 提示, 不算 mismatch
|
||||
const d2 = recipe2.days.find((x) => x.day === rd.day);
|
||||
if (!d2) { mismatch.push(`${rd.day} 未收集到`); continue; }
|
||||
for (const rm of rd.meals ?? []) {
|
||||
const m2 = d2.meals.find((x) => x.meal === rm.meal);
|
||||
const exp = rm.items ?? [];
|
||||
const got = m2?.items ?? [];
|
||||
if (got.length !== exp.length) { mismatch.push(`${rd.day} ${rm.meal} 条数 ${exp.length}→${got.length}`); continue; }
|
||||
for (let i = 0; i < exp.length; i++) {
|
||||
if ((got[i].zh ?? "") !== (exp[i].zh ?? "") || (got[i].en ?? "") !== (exp[i].en ?? "")) {
|
||||
mismatch.push(`${rd.day} ${rm.meal} #${i + 1} 文本不符`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const html = (body as any).innerHTML as string;
|
||||
return {
|
||||
html,
|
||||
report: { filled, warnings, repaired, mismatch, removedDays, verification: diffSnapshot(baseline, after) },
|
||||
};
|
||||
}
|
||||
|
||||
/** 提取模板当前食谱(可用于基线对照/测试) */
|
||||
export function extractTemplateRecipe(templateHtml: string): Recipe {
|
||||
const dom = new JSDOM(`<html><body>${templateHtml}</body></html>`, { url: "https://www.135editor.com/" });
|
||||
const body = dom.window.document.body as unknown as HTMLElement;
|
||||
return daysToRecipe(collect(body));
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.6.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"mysql2": "^3.22.0",
|
||||
@@ -28,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
Generated
+923
-606
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fillTemplate } from "../lib/editor135-core.ts";
|
||||
const tpl = readFileSync("../public/template135.html", "utf8");
|
||||
const DAY_EN: Record<string, string> = { "星期一": "Mon", "星期二": "Tue", "星期三": "Wed", "星期四": "Thu", "星期五": "Fri", "星期六": "Sat", "星期日": "Sun" };
|
||||
const mkDay = (day: string, counts: number[]) => {
|
||||
const mealNames = ["早餐", "早点", "午餐", "体弱儿餐", "午点"];
|
||||
return { day, meals: mealNames.slice(0, counts.length).map((meal, i) => ({
|
||||
meal, items: Array.from({ length: counts[i] }, (_, k) => ({ zh: day + meal + "菜" + (k + 1), en: DAY_EN[day] + "-M" + (i + 1) + "-D" + (k + 1) })),
|
||||
})) };
|
||||
};
|
||||
const strip = (h: string) => h.replace(/<[^>]+>/g, "");
|
||||
|
||||
// 场景1: 间隔天 一/三/五
|
||||
const r1 = fillTemplate(tpl, { days: [mkDay("星期一", [4, 3, 5, 1, 1]), mkDay("星期三", [4, 3, 5, 1, 1]), mkDay("星期五", [4, 3, 5, 1, 1])] });
|
||||
console.log("场景1(一/三/五) removedDays:", r1.report.removedDays, "mismatch:", r1.report.mismatch.length ? r1.report.mismatch : "无 ✓");
|
||||
for (const d of ["星期一", "星期二", "星期三", "星期四", "星期五"]) console.log(" " + d + " 残留:", strip(r1.html).split(d).length - 1, "(含合成菜名)");
|
||||
|
||||
// 场景2: 全周 + 周末(模板无周末区块)
|
||||
const r2 = fillTemplate(tpl, { days: ["星期一","星期二","星期三","星期四","星期五","星期六","星期日"].map((d) => mkDay(d, [4, 3, 5, 1, 1])) });
|
||||
console.log("\n场景2(全周+周末) removedDays:", r2.report.removedDays, "mismatch:", r2.report.mismatch.length ? r2.report.mismatch : "无 ✓");
|
||||
console.log(" warnings:", r2.report.warnings);
|
||||
|
||||
// 场景3: 只有周末
|
||||
const r3 = fillTemplate(tpl, { days: [mkDay("星期六", [4, 3, 5, 1, 1]), mkDay("星期日", [4, 3, 5, 1, 1])] });
|
||||
console.log("\n场景3(仅周末) removedDays:", r3.report.removedDays, "mismatch:", r3.report.mismatch.length ? r3.report.mismatch : "无 ✓");
|
||||
for (const d of ["星期一", "星期二", "星期三", "星期四", "星期五"]) console.log(" " + d + " 残留:", strip(r3.html).split(d).length - 1);
|
||||
console.log(" 尾部保留:", strip(r3.html).includes("三审三校") || strip(r3.html).includes("温馨提示"));
|
||||
|
||||
// 场景4: 一/四 (中间连续缺)
|
||||
const r4 = fillTemplate(tpl, { days: [mkDay("星期一", [4, 3, 5, 1, 1]), mkDay("星期四", [4, 3, 5, 1, 1])] });
|
||||
console.log("\n场景4(一/四) removedDays:", r4.report.removedDays, "mismatch:", r4.report.mismatch.length ? r4.report.mismatch : "无 ✓");
|
||||
|
||||
// 场景5: 条数不匹配(少/多) 触发增删路径
|
||||
const r5 = fillTemplate(tpl, { days: [mkDay("星期一", [2, 1, 7, 1, 3]), mkDay("星期二", [4, 3, 5, 1, 1]), mkDay("星期三", [4, 3, 5, 1, 1]), mkDay("星期四", [4, 3, 5, 1, 1]), mkDay("星期五", [4, 3, 5, 1, 1])] });
|
||||
console.log("\n场景5(增删路径) mismatch:", r5.report.mismatch.length ? r5.report.mismatch : "无 ✓");
|
||||
console.log(" filled:", r5.report.filled.filter((f) => f.day === "星期一").map((f) => f.meal + ":" + f.replaced + "+" + f.added + "-" + f.removed).join(", "));
|
||||
|
||||
// 场景6: 完整周正常填充(等价真实回归)
|
||||
const r6 = fillTemplate(tpl, { days: ["星期一","星期二","星期三","星期四","星期五"].map((d) => mkDay(d, [4, 3, 5, 1, 1])) });
|
||||
console.log("\n场景6(完整周) removedDays:", r6.report.removedDays, "mismatch:", r6.report.mismatch.length ? r6.report.mismatch : "无 ✓");
|
||||
console.log(" verification:", JSON.stringify(r6.report.verification));
|
||||
+1
-1
@@ -30,5 +30,5 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "scripts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user