From 6e1d2c672803fb7a480156fe71ea6564eab14c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E5=AF=92?= <2596194220@qq.com> Date: Tue, 8 Sep 2026 23:18:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E135=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E8=BD=AC=E6=8D=A2=E5=AD=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /editor135 子页面:上传幼儿园食谱docx → 解析并填充135模板 → 预览/源代码/解析结果三视图 - 新增 /api/editor135/render 解析接口(jsdom 解析填充,逐条回读校验 mismatch) - 新增 /api/editor135/img 微信CDN图片代理(解决预览防盗链,仅允许 *.qpic.cn) - 缺失星期自动删除:docx 缺某天(含周末/间隔天)时整段移除该天内容,尾部三审三校/温馨提示保留 - 逐节点精确区间删除算法:处理相邻天共用容器、嵌套结构,不误删保留天 - 新增依赖 jsdom + @types/jsdom;附 6 场景回归测试脚本 scripts/test-remove-days.ts - 首页 header 增加「135编辑器转换」导航入口 --- app/api/editor135/img/route.ts | 42 + app/api/editor135/render/route.ts | 51 + app/editor135/page.tsx | 424 ++++++++ app/page.tsx | 18 +- lib/editor135-core.ts | 613 ++++++++++++ package.json | 2 + pnpm-lock.yaml | 1529 +++++++++++++++++------------ public/template135.html | 1 + scripts/test-remove-days.ts | 41 + 9 files changed, 2110 insertions(+), 611 deletions(-) create mode 100644 app/api/editor135/img/route.ts create mode 100644 app/api/editor135/render/route.ts create mode 100644 app/editor135/page.tsx create mode 100644 lib/editor135-core.ts create mode 100644 public/template135.html create mode 100644 scripts/test-remove-days.ts diff --git a/app/api/editor135/img/route.ts b/app/api/editor135/img/route.ts new file mode 100644 index 0000000..449c96d --- /dev/null +++ b/app/api/editor135/img/route.ts @@ -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 }); + } +} diff --git a/app/api/editor135/render/route.ts b/app/api/editor135/render/route.ts new file mode 100644 index 0000000..4d4c373 --- /dev/null +++ b/app/api/editor135/render/route.ts @@ -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 }); + } +} diff --git a/app/editor135/page.tsx b/app/editor135/page.tsx new file mode 100644 index 0000000..b259487 --- /dev/null +++ b/app/editor135/page.tsx @@ -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(null); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + const [tab, setTab] = useState<"source" | "parsed">("source"); + const [openDays, setOpenDays] = useState>(new Set()); + const inputRef = useRef(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) => { + 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 ( +
+ {/* Header */} +
+
+
+
+ + + +
+

135编辑器转换

+

docx 食谱 → 公众号排版 HTML

+
+
+
+
+
+ +
+

+ 上传 docx 食谱文档,自动填充到 135编辑器 模板样式,输出可直接粘贴回 135编辑器 的 HTML 源代码(只改文本,不改模板样式) +

+ + {/* 错误态 */} + {error && ( +
+ + {error} +
+ )} + + {/* 无结果: 居中上传区 */} + {(!result || busy) && ( +
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 ? ( + <> + +

正在解析并填充模板…

+ + ) : ( + <> +
+ +
+

点击选择或拖拽 .docx 食谱文件到此处

+

仅支持 .docx 格式

+ + )} + +
+ )} + + {/* 结果区: 左侧(状态+源代码+解析) 右侧(预览) */} + {result && ( +
+ {/* ============ 左列 ============ */} +
+ {/* 紧凑上传区 */} +
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" + }`} + > + + 点击或拖拽,重新上传 docx + +
+ + {/* 校验状态条 */} +
+
+ + {result.fileName} + {result.report.mismatch.length === 0 ? ( + + 填充校验通过 + + ) : ( + + 校验有差异 + + )} +
+
+ 识别 {result.summary.days} 天 · {result.summary.totalItems} 条菜品 + 模板 {result.summary.templateDays} 天 · {result.summary.templateItems} 条 + + 样式{" "} + {result.report.verification.stylesAdded === 0 && result.report.verification.stylesRemoved === 0 ? ( + 零变化 + ) : ( + + +{result.report.verification.stylesAdded} / -{result.report.verification.stylesRemoved} + + )} + + + 图片{" "} + {result.report.verification.imgsUnchanged ? ( + 未改动 + ) : ( + 有变化! + )} + + 元素 {result.report.verification.elements} + {result.report.removedDays.length > 0 && ( + + 已按文档移除:{result.report.removedDays.join("、")} + + )} +
+
+ + +
+
+ + {/* 警示 */} + {(result.report.mismatch.length > 0 || result.report.warnings.length > 0) && ( +
+ {result.report.mismatch.length > 0 && ( +
+

填充差异(请检查):

+
    + {result.report.mismatch.slice(0, 10).map((m, i) =>
  • {m}
  • )} +
+
+ )} + {result.report.warnings.length > 0 && ( +
+

提示:

+
    + {result.report.warnings.slice(0, 10).map((w, i) =>
  • {w}
  • )} +
+
+ )} +
+ )} + + {/* 左列 Tabs */} +
+
+ {(["source", "parsed"] as const).map((t) => ( + + ))} +
+ + {/* 源代码: 自动换行完整展示 */} + {tab === "source" && ( +
+
+                      {result.html}
+                    
+

+ 共 {(result.html.length / 1024).toFixed(1)} KB · 全文已换行展示,可直接全选 · 复制后粘贴到 135编辑器「HTML源码」模式 +

+
+ )} + + {/* 解析结果: 按星期折叠面板 */} + {tab === "parsed" && ( +
+ {sortedDays.map((d) => { + const open = openDays.has(d.day); + const total = d.meals.reduce((n, m) => n + m.items.length, 0); + return ( +
+ + {open && ( +
+ {d.meals.map((m) => ( +
+
+ + {m.meal} + + {m.items.length} 条 +
+
+ {m.items.map((it, i) => ( +
0 ? "border-t" : "" + }`} + > + {it.zh || "—"} + {it.en} +
+ ))} +
+
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* 归一化修复记录 */} + {result.report.repaired.length > 0 && ( +
+

归一化修复(模板裸文本菜名)

+
    + {result.report.repaired.map((r, i) => ( +
  • {r.day} {r.meal}:{r.text}
  • + ))} +
+
+ )} +
+ + {/* ============ 右列: 预览(固定宽度, 内部滚动, 吸顶) ============ */} +
+
+