From 62a3d6ed14c9b159dbcf8d379bfb14096129ca33 Mon Sep 17 00:00:00 2001 From: Jacky Ser Date: Wed, 12 Aug 2026 16:23:15 +0800 Subject: [PATCH] Add product card image saving --- app/globals.css | 38 ++++-- app/page.tsx | 298 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 10 deletions(-) diff --git a/app/globals.css b/app/globals.css index 5ca3e48..2a1b228 100644 --- a/app/globals.css +++ b/app/globals.css @@ -358,10 +358,10 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado .catalog-card-body { padding: 16px; } .catalog-card-body > p { margin: 0 0 5px; color: #8793a5; font-size: 9px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .catalog-card-body h3 { height: 42px; margin: 0 0 12px; overflow: hidden; font-size: 14px; line-height: 1.5; } -.catalog-card-body dl { min-height: 83px; margin: 0; padding: 10px; border-radius: 9px; background: #f6f8fb; } -.catalog-card-body dl div { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; font-size: 9px; } -.catalog-card-body dt { color: #8a95a7; } -.catalog-card-body dd { max-width: 58%; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-weight: 650; } +.catalog-card-body dl { min-height: 0; margin: 0; padding: 10px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; border-radius: 9px; background: #f6f8fb; } +.catalog-card-body dl div { min-width: 0; display: grid; align-content: start; gap: 4px; padding: 3px 0; font-size: 9px; } +.catalog-card-body dt { overflow: hidden; color: #8a95a7; text-overflow: ellipsis; white-space: nowrap; } +.catalog-card-body dd { max-width: 100%; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-weight: 650; } .catalog-card-body > button { width: 100%; margin-top: 12px; @@ -375,6 +375,12 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado font-size: 10px; font-weight: 700; } +.catalog-card-body > .product-save-button { margin-top: 5px; padding: 8px 10px; border: 1px solid #d8e3f3; border-radius: 8px; color: #185ee8; background: #f7faff; } +.product-save-button { width: 100%; min-height: 33px; margin-top: 10px; padding: 8px 10px; display: inline-flex; align-items: center; justify-content: space-between; border: 1px solid #d8e3f3; border-radius: 8px; color: #185ee8; background: #f7faff; cursor: pointer; font-size: 10px; font-weight: 750; } +.product-save-button:hover { border-color: #a9c4f2; color: #104ec6; background: #eef5ff; } +.product-save-button span { font-size: 13px; line-height: 1; } +.product-save-button.is-saving { visibility: hidden; } +.product-save-toast { position: fixed; left: 50%; bottom: 24px; z-index: 120; transform: translateX(-50%); max-width: min(90vw, 360px); padding: 10px 15px; border: 1px solid #cbdaf2; border-radius: 999px; color: #174baf; background: rgba(248,251,255,.96); box-shadow: 0 12px 32px rgba(24,64,126,.18); font-size: 11px; font-weight: 750; text-align: center; backdrop-filter: blur(10px); } .precision-gear-panel { display: grid; grid-template-columns: minmax(260px, .8fr) 1.2fr; @@ -425,6 +431,7 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado .precision-gear-card-body dl div { display: flex; justify-content: space-between; gap: 5px; padding: 2px 0; font-size: 8px; } .precision-gear-card-body dt { color: #8a95a7; } .precision-gear-card-body dd { max-width: 62%; margin: 0; overflow: hidden; color: #52617a; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-weight: 700; } +.precision-gear-card-body .product-save-button { margin-top: 10px; } .precision-gear-empty { margin-top: 15px; } .precision-gear-load-more { margin-top: 22px; } .catalog-empty { padding: 60px 20px; text-align: center; border: 1px dashed #cbd5e3; border-radius: 15px; background: white; } @@ -492,12 +499,15 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado .catalog-modal-inline-info small { color: #96a0af; font-size: 8px; } .catalog-modal-image { min-height: min(56vh, 520px); display: flex; align-items: center; justify-content: center; border: 1px solid #e1e7f0; border-radius: 13px; overflow: hidden; background: #f5f7fb; } .catalog-modal-image img { display: block; max-width: 100%; max-height: min(56vh, 510px); width: 100%; height: auto; object-fit: contain; mix-blend-mode: multiply; } -.catalog-spec-table { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin-top: 18px; overflow: hidden; border: 1px solid #e0e5ed; border-radius: 11px; background: #e0e5ed; } -.catalog-spec-table div { min-height: 38px; padding: 7px 10px; display: grid; align-content: center; gap: 3px; background: white; } -.catalog-spec-table span { color: #8b96a7; font-size: 8px; } -.catalog-spec-table strong { font-size: 10px; } +.catalog-spec-table { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin-top: 18px; overflow: hidden; border: 1px solid #e0e5ed; border-radius: 11px; background: #e0e5ed; } +.catalog-spec-table div { min-width: 0; min-height: 58px; padding: 10px 12px; display: grid; align-content: center; gap: 4px; background: white; } +.catalog-spec-table span, +.catalog-spec-table strong { min-width: 0; overflow-wrap: anywhere; word-break: break-word; } +.catalog-spec-table span { color: #8b96a7; font-size: 8px; line-height: 1.35; } +.catalog-spec-table strong { font-size: 10px; line-height: 1.4; } .catalog-modal-actions { display: flex; align-items: center; gap: 18px; margin-top: 20px; } .catalog-modal-actions .panel-primary { flex: 0 0 auto; } +.catalog-modal-actions .modal-save-button { width: auto; min-width: 124px; margin-top: 0; flex: 0 0 auto; } .catalog-modal-actions p { margin: 0; color: #8b95a5; font-size: 9px; line-height: 1.6; } .how-section { padding: 100px max(32px, calc((100vw - 1180px) / 2)); display: grid; grid-template-columns: .9fr 1.1fr; gap: 100px; color: white; background: linear-gradient(135deg, #0c1528, #14284e); } .eyebrow.light { color: #77a5ff; } @@ -524,6 +534,7 @@ footer > span { color: #9aa4b2; font-size: 10px; } .catalog-grid { grid-template-columns: repeat(2, 1fr); } .precision-gear-panel { grid-template-columns: 1fr; } .precision-gear-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .catalog-spec-table { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 720px) { @@ -592,7 +603,10 @@ footer > span { color: #9aa4b2; font-size: 10px; } .precision-gear-card-body { padding: 9px; } .catalog-card-body { padding: 15px; } .catalog-card-body h3 { height: auto; min-height: 42px; font-size: 14px; } - .catalog-card-body dl { min-height: 90px; } + .catalog-card-body dl { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 9px 8px; } + .catalog-card-body dl div { min-width: 0; display: grid; align-content: start; justify-content: initial; gap: 3px; padding: 2px 0; font-size: 8px; } + .catalog-card-body dt { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } + .catalog-card-body dd { max-width: 100%; overflow: hidden; font-size: 9px; text-align: left; text-overflow: ellipsis; white-space: nowrap; } .catalog-modal-backdrop { padding: 10px; } .catalog-modal { padding: 22px 16px; } .catalog-modal-visual-toolbar { align-items: flex-start; flex-direction: column; gap: 10px; } @@ -600,8 +614,12 @@ footer > span { color: #9aa4b2; font-size: 10px; } .catalog-modal-inline-info h2 { font-size: 18px; } .catalog-modal-image { min-height: 290px; } .catalog-modal-image img { max-height: 290px; } - .catalog-spec-table { grid-template-columns: 1fr; } + .catalog-spec-table { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .catalog-spec-table div { min-height: 52px; padding: 8px 9px; gap: 3px; } + .catalog-spec-table span { font-size: 8px; } + .catalog-spec-table strong { font-size: 9px; } .catalog-modal-actions { align-items: stretch; flex-direction: column; } + .catalog-modal-actions .modal-save-button { width: 100%; } .products-section { padding: 75px 16px; } .how-section { grid-template-columns: 1fr; padding: 75px 20px; } footer { grid-template-columns: 1fr; justify-items: center; gap: 15px; } diff --git a/app/page.tsx b/app/page.tsx index 7a65c11..31ce841 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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((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((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(null); const [catalogVisualMode, setCatalogVisualMode] = useState("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 (
+ {saveProductMessage && ( +
+ {saveProductMessage} +
+ )}
@@ -936,6 +1184,19 @@ export default function Home() { {match.product.category} 价格与交期按用量确认 + ))} @@ -1123,6 +1384,18 @@ export default function Home() { + ))} @@ -1219,6 +1492,19 @@ export default function Home() {
{label}
{value}
))} + ))} @@ -1324,6 +1610,18 @@ export default function Home() { > 用AI继续确认需求 +

具体价格、交期及最终适配结果以工程师确认和正式文件为准。