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编辑器转换」导航入口
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user