Add product card image saving

This commit is contained in:
Jacky Ser
2026-08-12 16:23:15 +08:00
parent 81098ea237
commit 62a3d6ed14
2 changed files with 326 additions and 10 deletions

View File

@@ -350,6 +350,193 @@ function Metric({ label, value }: { label: string; value: string }) {
);
}
type SaveProductRequest = {
product: CatalogProduct;
badge?: string;
subtitle?: string;
extraSpecs?: Array<[string, string]>;
};
function getSaveProductSpecs(product: CatalogProduct, extraSpecs: Array<[string, string]> = []) {
return [
...extraSpecs.filter(([, value]) => value),
...Object.entries(product.specs).filter(([, value]) => value),
];
}
function wrapCanvasText(context: CanvasRenderingContext2D, value: string, maxWidth: number, maxLines = 2) {
const characters = Array.from(String(value));
const lines: string[] = [];
let current = "";
for (const character of characters) {
const next = current + character;
if (current && context.measureText(next).width > maxWidth) {
lines.push(current);
current = character;
if (lines.length === maxLines) break;
} else {
current = next;
}
}
if (lines.length < maxLines && current) lines.push(current);
if (lines.length === maxLines && characters.join("").length > lines.join("").length) {
const last = lines[maxLines - 1] ?? "";
lines[maxLines - 1] = `${last.slice(0, Math.max(1, last.length - 1))}`;
}
return lines;
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
fill: string,
stroke?: string,
) {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
context.fillStyle = fill;
context.fill();
if (stroke) {
context.strokeStyle = stroke;
context.lineWidth = 2;
context.stroke();
}
}
function loadProductCanvasImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`产品图片加载失败:${src}`));
image.src = src;
});
}
async function renderProductCardPng({ product, badge, subtitle, extraSpecs = [] }: SaveProductRequest) {
const specs = getSaveProductSpecs(product, extraSpecs);
const columnCount = 2;
const rowCount = Math.max(1, Math.ceil(specs.length / columnCount));
const cardWidth = 1200;
const padding = 52;
const headerHeight = 152;
const specCellHeight = 70;
const specGap = 10;
const mainHeight = Math.max(390, rowCount * specCellHeight + (rowCount - 1) * specGap + 28);
const cardHeight = padding + headerHeight + 24 + mainHeight + 24 + 42 + padding;
const canvas = document.createElement("canvas");
canvas.width = cardWidth;
canvas.height = cardHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("当前浏览器不支持图片生成");
context.fillStyle = "#eef3fa";
context.fillRect(0, 0, cardWidth, cardHeight);
drawRoundedRect(context, 18, 18, cardWidth - 36, cardHeight - 36, 26, "#ffffff", "#d9e3f2");
context.fillStyle = "#185ee8";
context.fillRect(padding, padding, 110, 7);
context.fillStyle = "#123267";
context.font = "800 25px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("GAUDELOT", padding, padding + 34);
context.fillStyle = "#7b8ca8";
context.font = "600 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("高徳乐产品资料", padding + 145, padding + 33);
if (badge) {
const badgeWidth = Math.max(100, context.measureText(badge).width + 34);
drawRoundedRect(context, cardWidth - padding - badgeWidth, padding + 8, badgeWidth, 32, 16, "#edf4ff");
context.fillStyle = "#185ee8";
context.font = "700 14px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText(badge, cardWidth - padding - badgeWidth + 17, padding + 29);
}
context.fillStyle = "#111d34";
context.font = "800 30px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
const titleLines = wrapCanvasText(context, product.name, 760, 2);
titleLines.forEach((line, index) => context.fillText(line, padding, padding + 82 + index * 36));
context.fillStyle = "#71809a";
context.font = "600 17px ui-monospace, SFMono-Regular, Menlo, monospace";
context.fillText(product.code, padding, padding + 145);
if (subtitle) {
context.fillStyle = "#8290a5";
context.font = "500 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText(subtitle, padding + 240, padding + 145);
}
const mainY = padding + headerHeight + 24;
const imageX = padding;
const imageWidth = 430;
const infoX = imageX + imageWidth + 24;
const infoWidth = cardWidth - padding - infoX;
drawRoundedRect(context, imageX, mainY, imageWidth, mainHeight, 18, "#f3f7fd", "#dce6f3");
drawRoundedRect(context, infoX, mainY, infoWidth, mainHeight, 18, "#f8faff", "#dce6f3");
try {
if (!product.image) throw new Error("没有产品主图");
const image = await loadProductCanvasImage(product.image);
const maxImageWidth = imageWidth - 34;
const maxImageHeight = mainHeight - 34;
const scale = Math.min(maxImageWidth / image.naturalWidth, maxImageHeight / image.naturalHeight);
const imageWidthOnCanvas = image.naturalWidth * scale;
const imageHeightOnCanvas = image.naturalHeight * scale;
context.drawImage(
image,
imageX + (imageWidth - imageWidthOnCanvas) / 2,
mainY + (mainHeight - imageHeightOnCanvas) / 2,
imageWidthOnCanvas,
imageHeightOnCanvas,
);
} catch {
context.fillStyle = "#9eb3d3";
context.font = "800 22px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("GAUDELOT", imageX + 132, mainY + mainHeight / 2);
}
context.fillStyle = "#6f809b";
context.font = "700 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("主要参数", infoX + 22, mainY + 31);
const cellsTop = mainY + 48;
const cellWidth = (infoWidth - 44 - specGap) / columnCount;
specs.forEach(([label, value], index) => {
const column = index % columnCount;
const row = Math.floor(index / columnCount);
const cellX = infoX + 22 + column * (cellWidth + specGap);
const cellY = cellsTop + row * (specCellHeight + specGap);
drawRoundedRect(context, cellX, cellY, cellWidth, specCellHeight, 10, "#ffffff", "#e4ebf4");
context.fillStyle = "#8b98ab";
context.font = "600 13px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
const labelLines = wrapCanvasText(context, label, cellWidth - 24, 1);
context.fillText(labelLines[0] ?? label, cellX + 12, cellY + 22);
context.fillStyle = "#15233d";
context.font = "800 16px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
const valueLines = wrapCanvasText(context, value, cellWidth - 24, 2);
valueLines.forEach((line, lineIndex) => context.fillText(line, cellX + 12, cellY + 47 + lineIndex * 18));
});
context.fillStyle = "#8b98ab";
context.font = "500 13px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("高徳乐官方产品目录 · 仅供选型参考,具体参数以正式文件为准", padding, cardHeight - padding - 11);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("PNG生成失败"))), "image/png");
});
}
export default function Home() {
const [stage, setStage] = useState(0);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -371,6 +558,8 @@ export default function Home() {
const [precisionGearFiltersOpen, setPrecisionGearFiltersOpen] = useState(false);
const [selectedCatalogProduct, setSelectedCatalogProduct] = useState<CatalogProduct | null>(null);
const [catalogVisualMode, setCatalogVisualMode] = useState<CatalogVisualMode>("main");
const [savingProductKey, setSavingProductKey] = useState("");
const [saveProductMessage, setSaveProductMessage] = useState("");
const matches = useMemo(() => getMatches(requirements), [requirements]);
const topMatches = matches.slice(0, 3);
@@ -458,6 +647,60 @@ export default function Home() {
setCatalogVisualMode("main");
}
async function saveProductCard(request: SaveProductRequest) {
if (savingProductKey) return;
const saveKey = `${request.product.id}-${request.badge ?? "product"}`;
setSavingProductKey(saveKey);
setSaveProductMessage("正在生成产品图片…");
try {
const blob = await renderProductCardPng(request);
const safeName = `${request.product.code}-${request.product.name}`
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, "-")
.slice(0, 90);
const fileName = `${safeName || request.product.id}.png`;
const file = new File([blob], fileName, { type: "image/png" });
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 0 && window.innerWidth <= 900);
const canShareFile = typeof navigator.share === "function" &&
(!navigator.canShare || navigator.canShare({ files: [file] }));
if (isMobile && canShareFile) {
try {
await navigator.share({
title: `${request.product.name} 产品资料`,
text: `高徳乐${request.product.category} · ${request.product.code}`,
files: [file],
});
setSaveProductMessage("已打开系统分享面板");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setSaveProductMessage("已取消保存");
} else {
throw error;
}
}
} else {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setSaveProductMessage("PNG图片已下载");
}
} catch (error) {
console.error("保存产品图片失败", error);
setSaveProductMessage("图片生成失败,请稍后重试");
} finally {
setSavingProductKey("");
window.setTimeout(() => setSaveProductMessage(""), 2800);
}
}
function startSelection() {
setMobileMenuOpen(false);
setStage(1);
@@ -488,6 +731,11 @@ export default function Home() {
return (
<main>
{saveProductMessage && (
<div className="product-save-toast" role="status" aria-live="polite">
{saveProductMessage}
</div>
)}
<header className="site-header">
<a className="brand" href="#top" aria-label="高德乐 AI选型首页">
<span className="brand-wordmark">
@@ -936,6 +1184,19 @@ export default function Home() {
<strong>{match.product.category}</strong>
<small></small>
</div>
<button
className={`product-save-button ${savingProductKey.startsWith(`${match.product.id}-`) ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product: match.product,
badge: index === 0 ? (match.coreCompatible ? "首选推荐" : "待复核候选") : `${match.confidence}% 匹配`,
subtitle: `${match.product.category} · ${match.product.size || "尺寸待复核"}`,
extraSpecs: [["AI匹配度", `${match.confidence}%`]],
})}
aria-label={`保存${match.product.name}产品图片`}
>
{savingProductKey.startsWith(`${match.product.id}-`) ? "正在生成…" : "保存产品"} <span></span>
</button>
</div>
</article>
))}
@@ -1123,6 +1384,18 @@ export default function Home() {
<button type="button" onClick={() => openCatalogProduct(product)}>
<span></span>
</button>
<button
className={`product-save-button ${savingProductKey === `${product.id}-${product.category}` ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product,
badge: product.category,
subtitle: "完整产品参数",
})}
aria-label={`保存${product.name}产品图片`}
>
{savingProductKey === `${product.id}-${product.category}` ? "正在生成…" : "保存产品"} <span></span>
</button>
</div>
</article>
))}
@@ -1219,6 +1492,19 @@ export default function Home() {
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
))}
</dl>
<button
className={`product-save-button ${savingProductKey === `${product.id}-${product.subCategory}` ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product,
badge: "精密齿轮配件",
subtitle: product.subCategory,
extraSpecs: [["产品子类", product.subCategory]],
})}
aria-label={`保存${product.name}产品图片`}
>
{savingProductKey === `${product.id}-${product.subCategory}` ? "正在生成…" : "保存产品"} <span></span>
</button>
</div>
</article>
))}
@@ -1324,6 +1610,18 @@ export default function Home() {
>
AI继续确认需求 <span></span>
</button>
<button
className={`product-save-button modal-save-button ${savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product: selectedCatalogProduct,
badge: selectedCatalogProduct.category,
subtitle: "完整产品参数",
})}
aria-label={`保存${selectedCatalogProduct.name}产品图片`}
>
{savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "正在生成…" : "保存产品"} <span></span>
</button>
<p></p>
</div>
</section>