"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}
  • ))}
)}
{/* ============ 右列: 预览(固定宽度, 内部滚动, 吸顶) ============ */}