diff --git a/app/catalog-image-map.ts b/app/catalog-image-map.ts index cde3ffc..f37bfd7 100644 --- a/app/catalog-image-map.ts +++ b/app/catalog-image-map.ts @@ -118,6 +118,18 @@ export function getCatalogProductImage(product: CatalogProduct) { return product.image; } +export function getCatalogProductThumbnail(src: string) { + if (src.includes("/catalog-products/precision-gears/")) { + return src.replace("/catalog-products/precision-gears/", "/catalog-products/precision-gears-thumb/").replace(/\.(?:jpe?g|png)$/i, ".webp"); + } + + if (src.includes("/catalog-products/source-main/")) { + return src.replace("/catalog-products/source-main/", "/catalog-products/source-main-thumb/").replace(/\.(?:jpe?g|png)$/i, ".webp"); + } + + return src; +} + export function getCatalogProductDimensionImage(product: CatalogProduct) { if (product.category === "精密齿轮配件") { return product.dimensionImage ?? null; diff --git a/app/globals.css b/app/globals.css index 58a0dd5..8319296 100644 --- a/app/globals.css +++ b/app/globals.css @@ -187,6 +187,11 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado .metric span { color: #8994a6; font-size: 8px; } .metric strong { font-size: 10px; } .reason-list { display: flex; gap: 10px; flex-wrap: wrap; margin: 12px 0 10px; padding: 0; list-style: none; color: #2d6e55; font-size: 9px; } +.recommendation-insights { display: grid; gap: 7px; margin: 9px 0 10px; } +.recommendation-insights > div { min-width: 0; padding: 7px 8px; border: 1px solid #e6edf6; border-radius: 8px; background: #fbfcfe; } +.recommendation-insights > div > strong { display: block; color: #68778e; font-size: 8px; } +.recommendation-insights .reason-list { margin: 5px 0 0; gap: 7px; font-size: 8px; } +.recommendation-insights p { margin: 5px 0 0; color: #53637b; font-size: 8px; line-height: 1.45; } .price-line { display: flex; align-items: baseline; gap: 6px; padding-top: 9px; border-top: 1px solid #edf0f4; } .price-line span { color: #7c8798; font-size: 9px; } .price-line strong { color: #e55726; font-size: 20px; } @@ -361,8 +366,8 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado .catalog-placeholder { color: #9eb3d3; font-size: 14px; font-weight: 800; letter-spacing: .12em; } .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: 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 h3 { min-height: 30px; max-height: 42px; height: auto; margin: 0 0 4px; overflow: hidden; font-size: 14px; line-height: 1.5; } +.catalog-card-body dl { min-height: 0; margin: 0; padding: 10px; display: grid; grid-template-columns: repeat(4, 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; } @@ -385,6 +390,12 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado .catalog-card-body > .add-cart-button { margin-top: 5px; } .precision-gear-card-body .add-cart-button { margin-top: 10px; } .recommendation-content .add-cart-button { margin-top: 10px; } +.spec-sheet-button { width: 100%; min-height: 33px; margin-top: 5px; padding: 8px 10px; display: inline-flex; align-items: center; justify-content: space-between; border: 1px solid #f1c68f; border-radius: 8px; color: #a76219; background: #fffaf2; cursor: pointer; font-size: 10px; font-weight: 750; } +.spec-sheet-button:hover { border-color: #e6a95d; color: #8d4e0e; background: #fff3df; } +.spec-sheet-button span { font-size: 14px; line-height: 1; } +.catalog-card-body > .spec-sheet-button { margin-top: 5px; } +.precision-gear-card-body .spec-sheet-button { margin-top: 5px; } +.recommendation-content .spec-sheet-button { margin-top: 5px; } .cart-toast { position: fixed; left: 50%; bottom: 24px; z-index: 120; transform: translateX(-50%); max-width: min(90vw, 380px); 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); } .cart-drawer-backdrop { position: fixed; inset: 0; z-index: 110; display: flex; justify-content: flex-end; background: rgba(7,14,29,.45); backdrop-filter: blur(4px); } .cart-drawer { width: min(430px, 100%); height: 100%; padding: 28px 24px 22px; display: flex; flex-direction: column; overflow: hidden; background: white; box-shadow: -18px 0 50px rgba(20,43,82,.2); } @@ -601,6 +612,7 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado .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-cart-button { width: auto; min-width: 124px; margin-top: 0; flex: 0 0 auto; } +.catalog-modal-actions .spec-sheet-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; } @@ -704,8 +716,8 @@ footer > span { color: #9aa4b2; font-size: 10px; } .precision-gear-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; } .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: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 9px 8px; } + .catalog-card-body h3 { height: auto; min-height: 30px; max-height: 42px; margin-bottom: 4px; font-size: 14px; } + .catalog-card-body dl { min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; padding: 9px 7px; } .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; } @@ -730,6 +742,8 @@ footer > span { color: #9aa4b2; font-size: 10px; } @media (max-width: 380px) { .catalog-spec-table { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .cart-drawer-footer > div { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .cart-sample-form-actions { grid-template-columns: 1fr; } } @media print { diff --git a/app/layout.tsx b/app/layout.tsx index f1b60e7..5d31f7d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,10 +11,10 @@ export async function generateMetadata(): Promise { return { metadataBase, title: "高徳乐 AI 舵机选型助手", - description: "高徳乐280个产品型号完整目录,支持搜索筛选、AI舵机初选与样品申请。", + description: "高徳乐243款舵机+配件,共286个产品型号,支持搜索筛选、AI舵机初选与样品申请。", openGraph: { title: "高徳乐 AI 舵机选型助手", - description: "280个型号完整目录。3分钟找到适合你的舵机,AI初选,工程师复核。", + description: "243款舵机+配件,共286个型号。3分钟找到适合你的舵机,AI初选,工程师复核。", images: [{ url: new URL("/og.png", metadataBase).toString(), width: 1200, height: 630 }], type: "website", }, diff --git a/app/page.tsx b/app/page.tsx index 26376da..8f40e65 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,7 +8,7 @@ import { } from "./catalog-data"; import { STEERING_WHEEL_ACCESSORIES } from "./catalog-accessories"; import { PRECISION_GEAR_PRODUCTS, PRECISION_GEAR_SUBCATEGORIES } from "./precision-gear-data"; -import { getCatalogProductDimensionImage, getCatalogProductImage } from "./catalog-image-map"; +import { getCatalogProductDimensionImage, getCatalogProductImage, getCatalogProductThumbnail } from "./catalog-image-map"; const CATALOG_PRODUCTS = [...RAW_CATALOG_PRODUCTS, ...STEERING_WHEEL_ACCESSORIES].map((product) => ({ ...product, @@ -59,6 +59,7 @@ const FEATURED_SPEC_ORDER = [ "堵转扭矩", "额定电压", "电压范围", + "可操作角度", "信号类型", "通讯方式", "齿轮比", @@ -74,7 +75,7 @@ const FEATURED_SPEC_ORDER = [ function getFeaturedSpecs(product: CatalogProduct) { return FEATURED_SPEC_ORDER .filter((label) => product.specs[label]) - .slice(0, 3) + .slice(0, 4) .map((label) => [label, product.specs[label]] as const); } @@ -232,12 +233,16 @@ const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) => ).map((product) => { const voltageValues = parseNumbers(product.specs["电压范围"]); const ratedVoltage = product.ratedVoltage ?? 0; - const control = [ + const controlSearch = [ product.specs["信号类型"], product.specs["通讯方式"], product.specs["端子连接线"], product.specs["连接线类型"], ].filter(Boolean).join(" · "); + const controlProtocol = [product.specs["信号类型"], product.specs["通讯方式"]] + .filter(Boolean).join(" · ") || "目录未标注"; + const terminalConnection = [product.specs["端子连接线"], product.specs["连接线类型"]] + .filter(Boolean).join(" · ") || "目录未标注"; return { ...product, torque: product.torqueValue ?? 0, @@ -247,7 +252,9 @@ const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) => isSilent: isSilentServo(product), gear: classifyGear(product.specs["齿轮材质"]), angle: product.specs["可操作角度"] ?? product.specs["操作角度范围"] ?? "", - control, + controlSearch, + controlProtocol, + terminalConnection, speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "", size: product.specs["产品尺寸"] ?? product.specs["外形尺寸"] ?? "", }; @@ -288,7 +295,7 @@ function getMatches(requirements: Requirements) { requirements.weightLimit === 0 || product.weight === null || product.weight <= requirements.weightLimit; const gearOk = requirements.gear === "不限" || requirements.gear === product.gear; const angleOk = angleMatches(requirements.angle, product.angle); - const controlOk = controlMatches(requirements.control, product.control); + const controlOk = controlMatches(requirements.control, product.controlSearch); const coreCompatible = categoryOk && torqueOk && voltageOk && weightOk && gearOk && angleOk && controlOk; if (categoryOk) score += requirements.category === "全部舵机" ? 6 : 16; @@ -341,6 +348,37 @@ function getMatches(requirements: Requirements) { }).sort((a, b) => Number(b.coreCompatible) - Number(a.coreCompatible) || b.score - a.score); } +type SelectorMatch = ReturnType[number]; + +function getRecommendationDifference(current: SelectorMatch, recommendations: SelectorMatch[]) { + const peers = recommendations.filter((match) => match.product.id !== current.product.id); + if (peers.length === 0) return "暂无可比较的其他推荐型号。"; + + const maxTorque = Math.max(current.product.torque, ...peers.map((match) => match.product.torque)); + if (current.product.torque === maxTorque && peers.some((match) => match.product.torque < current.product.torque)) { + return `堵转扭矩余量最高(${formatTorque(current.product.torque)} kgf·cm),适合优先验证负载余量。`; + } + + const knownWeights = [current, ...peers].map((match) => match.product.weight).filter((value): value is number => value !== null); + if (current.product.weight !== null && knownWeights.length > 1 && current.product.weight === Math.min(...knownWeights) && peers.some((match) => match.product.weight !== null && match.product.weight > current.product.weight)) { + return `重量更轻(${current.product.weight}g),更适合对空间或运动惯量敏感的结构。`; + } + + if (current.product.controlProtocol !== "目录未标注" && peers.some((match) => match.product.controlProtocol !== current.product.controlProtocol)) { + return `控制协议为${current.product.controlProtocol};其他推荐款的协议字段不同,请按主控接口确认。`; + } + + if (current.product.terminalConnection !== "目录未标注" && peers.some((match) => match.product.terminalConnection !== current.product.terminalConnection)) { + return `端子/连接线为${current.product.terminalConnection},与另外两款的接线规格不同。`; + } + + if (current.product.angle && peers.some((match) => match.product.angle !== current.product.angle)) { + return `可操作角度为${current.product.angle},与另外两款的角度范围不同。`; + } + + return "核心参数接近,主要差异请结合产品尺寸、端子和完整参数确认。"; +} + function Metric({ label, value }: { label: string; value: string }) { return (
@@ -365,6 +403,9 @@ type CartExportImage = { const CART_EXPORT_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; const CART_IMAGE_SIZE_PX = 189; // 约 5 cm,按 96 DPI 换算 +const PDF_MIME = "application/pdf"; +const PDF_PAGE_WIDTH = 595; +const PDF_PAGE_HEIGHT = 842; function getCartExportFilename() { const now = new Date(); @@ -373,6 +414,10 @@ function getCartExportFilename() { return `高徳乐产品清单_${date}.xlsx`; } +function getCartPdfFilename() { + return getCartExportFilename().replace(/\.xlsx$/, ".pdf"); +} + function getFullSpecEntries(product: CatalogProduct) { return Object.entries(product.specs).filter(([, value]) => value); } @@ -442,7 +487,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) { summarySheet.views = [{ state: "frozen", ySplit: 1 }]; detailsSheet.views = [{ state: "frozen", ySplit: 1 }]; summarySheet.columns = [ - { header: "产品图片", key: "image", width: 27 }, + { header: "产品图片", key: "image", width: 36 }, { header: "产品名称", key: "name", width: 34 }, { header: "产品编码", key: "code", width: 24 }, { header: "产品分类", key: "category", width: 18 }, @@ -507,7 +552,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) { if (imageAsset) { const imageId = workbook.addImage(imageAsset); summarySheet.addImage(imageId, { - tl: { col: 0.1, row: summaryRow.number - 1 + 0.08 }, + tl: { col: 0.25, row: summaryRow.number - 1 + 0.08 }, ext: { width: CART_IMAGE_SIZE_PX, height: CART_IMAGE_SIZE_PX }, }); summaryRow.getCell(1).value = null; @@ -543,7 +588,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) { return workbook; } -function downloadCartWorkbook(blob: Blob, filename: string) { +function downloadFile(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; @@ -559,6 +604,330 @@ function isMobileDevice() { (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); } +type NativePdfImage = { + bytes: Uint8Array; + height: number; + name: string; + width: number; +}; + +type NativePdfPage = { + images: Set; + parts: string[]; +}; + +type NativePdfDocument = { + images: Map; +}; + +function pdfRgb(hex: string) { + const value = Number.parseInt(hex.replace("#", ""), 16); + return `${((value >> 16) & 255) / 255} ${((value >> 8) & 255) / 255} ${(value & 255) / 255}`; +} + +function pdfUtf16Hex(value: string) { + const codeUnits: number[] = [0xfeff]; + for (const character of String(value)) { + const codePoint = character.codePointAt(0) ?? 0x20; + if (codePoint <= 0xffff) { + codeUnits.push(codePoint); + } else { + const adjusted = codePoint - 0x10000; + codeUnits.push(0xd800 + (adjusted >> 10), 0xdc00 + (adjusted & 0x3ff)); + } + } + return codeUnits.map((unit) => unit.toString(16).padStart(4, "0")).join("").toUpperCase(); +} + +function pdfTextWidth(value: string, fontSize: number) { + return Array.from(String(value)).reduce((width, character) => { + const isAscii = (character.codePointAt(0) ?? 0) < 0x80; + return width + fontSize * (isAscii ? 0.54 : 0.98); + }, 0); +} + +function wrapPdfText(value: string, maxWidth: number, fontSize: number, maxLines = 4) { + const lines: string[] = []; + for (const paragraph of String(value).split("\n")) { + let current = ""; + for (const character of Array.from(paragraph)) { + const next = current + character; + if (current && pdfTextWidth(next, fontSize) > maxWidth) { + lines.push(current); + current = character; + if (lines.length >= maxLines) return lines; + } else { + current = next; + } + } + if (current) lines.push(current); + if (lines.length >= maxLines) return lines; + } + return lines.length > 0 ? lines : [""]; +} + +function nativePdfPage(): NativePdfPage { + return { images: new Set(), parts: [] }; +} + +function pdfFillRect(page: NativePdfPage, x: number, top: number, width: number, height: number, color: string) { + page.parts.push(`${pdfRgb(color)} rg ${x} ${PDF_PAGE_HEIGHT - top - height} ${width} ${height} re f`); +} + +function pdfStrokeRect(page: NativePdfPage, x: number, top: number, width: number, height: number, color = "#dbe4ef") { + page.parts.push(`${pdfRgb(color)} RG 0.6 w ${x} ${PDF_PAGE_HEIGHT - top - height} ${width} ${height} re S`); +} + +function pdfText( + page: NativePdfPage, + value: string, + x: number, + baselineFromTop: number, + fontSize: number, + color = "#17233a", + align: "left" | "center" | "right" = "left", +) { + const textWidth = pdfTextWidth(value, fontSize); + const adjustedX = align === "center" ? x - textWidth / 2 : align === "right" ? x - textWidth : x; + page.parts.push(`BT /F1 ${fontSize} Tf ${pdfRgb(color)} rg 1 0 0 1 ${adjustedX.toFixed(2)} ${(PDF_PAGE_HEIGHT - baselineFromTop).toFixed(2)} Tm <${pdfUtf16Hex(value)}> Tj ET`); +} + +function pdfWrappedText( + page: NativePdfPage, + value: string, + x: number, + baselineFromTop: number, + maxWidth: number, + fontSize: number, + lineHeight: number, + maxLines = 4, + color = "#17233a", + align: "left" | "center" | "right" = "left", +) { + wrapPdfText(value, maxWidth, fontSize, maxLines).forEach((line, index) => { + pdfText(page, line, align === "left" ? x : x + maxWidth / (align === "center" ? 2 : 1), baselineFromTop + index * lineHeight, fontSize, color, align); + }); +} + +function pdfImageContain(page: NativePdfPage, image: NativePdfImage, x: number, top: number, width: number, height: number) { + const scale = Math.min(width / image.width, height / image.height); + const imageWidth = image.width * scale; + const imageHeight = image.height * scale; + const imageX = x + (width - imageWidth) / 2; + const imageTop = top + (height - imageHeight) / 2; + page.images.add(image.name); + page.parts.push(`q ${imageWidth.toFixed(2)} 0 0 ${imageHeight.toFixed(2)} ${imageX.toFixed(2)} ${(PDF_PAGE_HEIGHT - imageTop - imageHeight).toFixed(2)} cm /${image.name} Do Q`); +} + +function decodeBase64(value: string) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +async function getNativePdfImage(document: NativePdfDocument, src: string) { + const existing = document.images.get(src); + if (existing) return existing; + const compressed = await compressCartImage(src); + if (!compressed) return null; + const image: NativePdfImage = { + bytes: decodeBase64(compressed.base64.split(",")[1] ?? ""), + height: CART_IMAGE_SIZE_PX, + name: `Im${document.images.size + 1}`, + width: CART_IMAGE_SIZE_PX, + }; + document.images.set(src, image); + return image; +} + +async function renderCartPdfSummaryPage(items: CartItem[], pageNumber: number, pageCount: number, document: NativePdfDocument) { + const page = nativePdfPage(); + pdfFillRect(page, 42, 32, 82, 3, "#185ee8"); + pdfText(page, "GAUDELOT · 高徳乐", 42, 58, 15, "#17233a"); + pdfText(page, "产品清单", 42, 92, 22, "#17233a"); + pdfText(page, `购物车共 ${items.length} 个型号 / ${items.reduce((total, item) => total + item.quantity, 0)} 件产品`, 42, 112, 9, "#71809a"); + pdfText(page, getCartPdfFilename(), PDF_PAGE_WIDTH - 42, 58, 8, "#71809a", "right"); + + const left = 42; + const tableTop = 140; + const tableWidth = PDF_PAGE_WIDTH - left * 2; + const columnWidths = [58, 100, 75, 60, 30, tableWidth - 58 - 100 - 75 - 60 - 30]; + const headers = ["产品图片", "产品名称", "产品编码", "产品分类", "数量", "主要参数"]; + const headerHeight = 23; + let x = left; + headers.forEach((header, index) => { + pdfFillRect(page, x, tableTop, columnWidths[index], headerHeight, "#185ee8"); + pdfText(page, header, x + columnWidths[index] / 2, tableTop + 15, 7.3, "#ffffff", "center"); + x += columnWidths[index]; + }); + + const rowHeight = 80; + for (const [rowIndex, item] of items.entries()) { + const rowTop = tableTop + headerHeight + rowIndex * rowHeight; + pdfFillRect(page, left, rowTop, tableWidth, rowHeight, rowIndex % 2 === 0 ? "#f8faff" : "#ffffff"); + pdfStrokeRect(page, left, rowTop, tableWidth, rowHeight); + x = left; + for (const width of columnWidths.slice(0, -1)) { + x += width; + page.parts.push(`${pdfRgb("#dbe4ef")} RG 0.6 w ${x} ${PDF_PAGE_HEIGHT - rowTop - rowHeight} m ${x} ${PDF_PAGE_HEIGHT - rowTop} l S`); + } + + if (item.product.image) { + const image = await getNativePdfImage(document, item.product.image); + if (image) pdfImageContain(page, image, left + 5, rowTop + 7, columnWidths[0] - 10, rowHeight - 14); + else pdfText(page, "暂无图片", left + columnWidths[0] / 2, rowTop + 42, 7, "#8da5c8", "center"); + } + + const textCells = [item.product.name, item.product.code, item.product.category, String(item.quantity), getKeySpecText(item.product)]; + let cellX = left + columnWidths[0]; + textCells.forEach((value, cellIndex) => { + const width = columnWidths[cellIndex + 1]; + const color = cellIndex === 4 ? "#52617a" : "#17233a"; + const fontSize = cellIndex === 4 ? 6.4 : 7.4; + if (cellIndex === 3) { + pdfText(page, value, cellX + width / 2, rowTop + 43, fontSize, color, "center"); + } else { + pdfWrappedText(page, value, cellX + 5, rowTop + 19, width - 10, fontSize, 9, cellIndex === 4 ? 7 : 4, color); + } + cellX += width; + }); + } + + pdfText(page, "高徳乐 AI 舵机选型助手 · 具体价格、交期及最终适配结果以工程师确认和正式文件为准", 42, PDF_PAGE_HEIGHT - 30, 7, "#8492a7"); + pdfText(page, `${pageNumber} / ${pageCount}`, PDF_PAGE_WIDTH - 42, PDF_PAGE_HEIGHT - 30, 7, "#8492a7", "right"); + return page; +} + +async function renderCartPdfDetailPage(items: CartItem[], pageNumber: number, pageCount: number, document: NativePdfDocument) { + const page = nativePdfPage(); + pdfFillRect(page, 42, 28, 82, 3, "#185ee8"); + pdfText(page, "详细参数 · 每页 3 款", 42, 51, 9, "#185ee8"); + + const left = 42; + const cardWidth = PDF_PAGE_WIDTH - left * 2; + const cardTop = 68; + const cardGap = 8; + const columns = 4; + const cellWidth = cardWidth / columns; + const maxRows = Math.max(1, ...items.map((item) => Math.ceil(getFullSpecEntries(item.product).length / columns))); + const cardHeight = Math.max(158, 46 + maxRows * 38); + + for (const [itemIndex, item] of items.entries()) { + const cardY = cardTop + itemIndex * (cardHeight + cardGap); + pdfFillRect(page, left, cardY, cardWidth, cardHeight, itemIndex % 2 === 0 ? "#f6f9fd" : "#fbfcfe"); + pdfStrokeRect(page, left, cardY, cardWidth, cardHeight); + pdfWrappedText(page, item.product.name, left + 9, cardY + 17, cardWidth - 70, 10.5, 12, 2, "#17233a"); + pdfText(page, `${item.product.code} · ${item.product.category} · 数量 ${item.quantity}`, left + 9, cardY + 34, 6.4, "#71809a"); + + if (item.product.image) { + const image = await getNativePdfImage(document, item.product.image); + if (image) pdfImageContain(page, image, left + cardWidth - 47, cardY + 6, 38, 26); + } + + const entries = getFullSpecEntries(item.product); + const rows = Math.max(1, Math.ceil(entries.length / columns)); + const gridTop = cardY + 46; + const cellHeight = Math.min(38, Math.max(30, Math.floor((cardHeight - 46) / rows))); + (entries.length > 0 ? entries : [["暂无参数", ""]]).forEach(([label, value], index) => { + const column = index % columns; + const row = Math.floor(index / columns); + const x = left + column * cellWidth; + const y = gridTop + row * cellHeight; + pdfFillRect(page, x, y, cellWidth, cellHeight, row % 2 === 0 ? "#ffffff" : "#f8fafd"); + pdfStrokeRect(page, x, y, cellWidth, cellHeight); + pdfWrappedText(page, label, x + 6, y + 12, cellWidth - 12, 6.1, 7, 2, "#8492a7"); + pdfWrappedText(page, value, x + 6, y + Math.min(26, cellHeight - 9), cellWidth - 12, 7.2, 8, 2, "#17233a"); + }); + } + + pdfText(page, "高徳乐 AI 舵机选型助手 · 详细参数以正式技术文件为准", 42, PDF_PAGE_HEIGHT - 30, 7, "#8492a7"); + pdfText(page, `${pageNumber} / ${pageCount}`, PDF_PAGE_WIDTH - 42, PDF_PAGE_HEIGHT - 30, 7, "#8492a7", "right"); + return page; +} + +function buildNativePdf(pages: NativePdfPage[], images: Map) { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + const offsets: number[] = [0]; + let length = 0; + const append = (chunk: Uint8Array) => { + chunks.push(chunk); + length += chunk.length; + }; + const appendText = (value: string) => append(encoder.encode(value)); + const imageEntries = [...images.values()]; + const imageObjectNumbers = new Map(imageEntries.map((image, index) => [image.name, 5 + index])); + const pageObjectStart = 5 + imageEntries.length; + const pageObjectNumbers = pages.map((_, index) => pageObjectStart + index * 2); + const contentObjectNumbers = pages.map((_, index) => pageObjectStart + index * 2 + 1); + const objectCount = pageObjectStart + pages.length * 2 - 1; + + appendText("%PDF-1.4\n%\xFF\xFF\xFF\xFF\n"); + const addObject = (number: number, content: Uint8Array | Uint8Array[]) => { + offsets[number] = length; + appendText(`${number} 0 obj\n`); + for (const chunk of Array.isArray(content) ? content : [content]) append(chunk); + appendText("\nendobj\n"); + }; + + addObject(1, encoder.encode("<< /Type /Catalog /Pages 2 0 R >>")); + addObject(2, encoder.encode(`<< /Type /Pages /Kids [${pageObjectNumbers.map((number) => `${number} 0 R`).join(" ")}] /Count ${pages.length} >>`)); + addObject(3, encoder.encode("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [4 0 R] >>")); + addObject(4, encoder.encode("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>")); + + imageEntries.forEach((image, index) => { + const imageObject = 5 + index; + addObject(imageObject, [ + encoder.encode(`<< /Type /XObject /Subtype /Image /Width ${image.width} /Height ${image.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.bytes.length} >>\nstream\n`), + image.bytes, + encoder.encode("\nendstream"), + ]); + }); + + pages.forEach((page, index) => { + const content = encoder.encode(page.parts.join("\n") + "\n"); + const contentObject = contentObjectNumbers[index]; + addObject(contentObject, [encoder.encode(`<< /Length ${content.length} >>\nstream\n`), content, encoder.encode("endstream")]); + const xObjects = [...page.images] + .map((name) => `/${name} ${imageObjectNumbers.get(name)} 0 R`) + .join(" "); + addObject(pageObjectNumbers[index], encoder.encode(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PDF_PAGE_WIDTH} ${PDF_PAGE_HEIGHT}] /Resources << /Font << /F1 3 0 R >> /XObject << ${xObjects} >> >> /Contents ${contentObject} 0 R >>`)); + }); + + const xrefOffset = length; + appendText(`xref\n0 ${objectCount + 1}\n0000000000 65535 f \n`); + for (let index = 1; index <= objectCount; index += 1) { + appendText(`${String(offsets[index] ?? 0).padStart(10, "0")} 00000 n \n`); + } + appendText(`trailer\n<< /Size ${objectCount + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`); + + const result = new Uint8Array(length); + let cursor = 0; + chunks.forEach((chunk) => { + result.set(chunk, cursor); + cursor += chunk.length; + }); + return result; +} + +async function buildCartPdf(cartItems: CartItem[]) { + const summaryChunkSize = 7; + const summaryPages = Math.max(1, Math.ceil(cartItems.length / summaryChunkSize)); + const detailChunkSize = 3; + const detailPages = Math.max(1, Math.ceil(cartItems.length / detailChunkSize)); + const totalPages = summaryPages + detailPages; + const document: NativePdfDocument = { images: new Map() }; + const pages: NativePdfPage[] = []; + for (let start = 0; start < cartItems.length; start += summaryChunkSize) { + pages.push(await renderCartPdfSummaryPage(cartItems.slice(start, start + summaryChunkSize), pages.length + 1, totalPages, document)); + } + for (let start = 0; start < cartItems.length; start += detailChunkSize) { + pages.push(await renderCartPdfDetailPage(cartItems.slice(start, start + detailChunkSize), pages.length + 1, totalPages, document)); + } + return new Blob([buildNativePdf(pages, document.images) as BlobPart], { type: PDF_MIME }); +} + /* type SaveProductRequest = { product: CatalogProduct; @@ -782,6 +1151,7 @@ export default function Home() { const [cartOpen, setCartOpen] = useState(false); const [cartMessage, setCartMessage] = useState(""); const [exportingCart, setExportingCart] = useState(false); + const [exportingPdf, setExportingPdf] = useState(false); const [cartSampleFormOpen, setCartSampleFormOpen] = useState(false); const [cartSampleSubmitted, setCartSampleSubmitted] = useState(false); const [cartSampleLeadId, setCartSampleLeadId] = useState(""); @@ -896,6 +1266,11 @@ export default function Home() { window.setTimeout(() => setCartMessage(""), 2200); } + function requestSpecSheet(product: CatalogProduct) { + setCartMessage(`${product.name} 的产品规格书资料待补充`); + window.setTimeout(() => setCartMessage(""), 2400); + } + function addTopMatchesToCart() { if (topMatches.length === 0) return; setCartItems((current) => { @@ -975,7 +1350,7 @@ export default function Home() { } } - downloadCartWorkbook(file, filename); + downloadFile(file, filename); setCartMessage("产品清单已下载"); } catch (error) { console.error("生成购物车清单失败", error); @@ -986,24 +1361,42 @@ export default function Home() { } } - function exportCartPdf() { - if (cartItems.length === 0) return; - const previousTitle = document.title; - document.body.classList.add("printing-cart"); - document.title = getCartExportFilename().replace(/\.xlsx$/, ".pdf"); - setCartOpen(false); - let fallbackCleanupTimer = 0; - const cleanupPrintMode = () => { - window.clearTimeout(fallbackCleanupTimer); - window.removeEventListener("afterprint", cleanupPrintMode); - document.body.classList.remove("printing-cart"); - document.title = previousTitle; - }; - window.addEventListener("afterprint", cleanupPrintMode); - fallbackCleanupTimer = window.setTimeout(cleanupPrintMode, 30000); - window.setTimeout(() => { - window.print(); - }, 80); + async function exportCartPdf() { + if (cartItems.length === 0 || exportingPdf) return; + + setExportingPdf(true); + setCartMessage("正在生成 PDF…"); + try { + const blob = await buildCartPdf(cartItems); + const filename = getCartPdfFilename(); + const file = new File([blob], filename, { type: PDF_MIME }); + + if (isMobileDevice() && typeof navigator.share === "function") { + const canShareFiles = typeof navigator.canShare !== "function" || navigator.canShare({ files: [file] }); + if (canShareFiles) { + try { + await navigator.share({ + title: "高徳乐产品清单", + text: "购物车产品清单 PDF", + files: [file], + }); + setCartMessage("已打开系统分享面板"); + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + } + } + } + + downloadFile(file, filename); + setCartMessage("PDF 文件已下载"); + } catch (error) { + console.error("生成购物车 PDF 失败", error); + setCartMessage("PDF 生成失败,请稍后重试"); + } finally { + setExportingPdf(false); + window.setTimeout(() => setCartMessage(""), 2400); + } } function startSelection() { @@ -1104,7 +1497,7 @@ export default function Home() { {cartItems.map((item) => (
- {item.product.image ? : G} + {item.product.image ? : G}
{item.source} @@ -1139,10 +1532,10 @@ export default function Home() { className="outline-button cart-pdf-button" type="button" onClick={exportCartPdf} - disabled={cartItems.length === 0} - aria-label="导出购物车 PDF" + disabled={cartItems.length === 0 || exportingPdf} + aria-label={exportingPdf ? "正在生成购物车 PDF" : "导出购物车 PDF"} > - 导出 PDF + {exportingPdf ? "生成中…" : "导出 PDF"} @@ -1173,7 +1566,7 @@ export default function Home() {

产品清单

购物车共 {cartItems.length} 个型号 / {cartCount} 件产品
- {getCartExportFilename().replace(/\.xlsx$/, ".pdf")} + {getCartPdfFilename()}
@@ -1227,10 +1620,10 @@ export default function Home() {

高徳乐 AI 舵机选型助手 · 本清单用于客户沟通、选型确认和打印留档,最终参数以正式技术文件为准。

- + GAUDELOT - 高德乐 + 高徳乐