Files
recipe_tool/app/editor135/page.tsx
T
hanhan 6e1d2c6728 feat: 新增135编辑器转换子功能
- 新增 /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编辑器转换」导航入口
2026-09-08 23:18:53 +08:00

425 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}