"use client"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { CATALOG_COUNTS, CATALOG_PRODUCTS as RAW_CATALOG_PRODUCTS, type CatalogProduct, } from "./catalog-data"; import { STEERING_WHEEL_ACCESSORIES } from "./catalog-accessories"; import { PRECISION_GEAR_PRODUCTS, PRECISION_GEAR_SUBCATEGORIES } from "./precision-gear-data"; import { getCatalogProductDimensionImage, getCatalogProductImage, getCatalogProductThumbnail } from "./catalog-image-map"; const CATALOG_PRODUCTS = [...RAW_CATALOG_PRODUCTS, ...STEERING_WHEEL_ACCESSORIES].map((product) => ({ ...product, image: getCatalogProductImage(product), })); type Requirements = { application: string; category: string; torque: number; voltage: number; weightLimit: number; angle: string; control: string; gear: string; annualVolume: string; firstOrderQuantity: string; currentModel: string; otherRequirements: string; painPoint: string; }; type CatalogVisualMode = "main" | "size"; const APPLICATIONS = [ { name: "AI毛绒", note: "头部、耳朵、手臂等轻量动作" }, { name: "智能玩具", note: "互动机构、表情和行走结构" }, { name: "教育/积木", note: "STEAM套件与积木动力模块" }, { name: "微型机器人", note: "关节、夹爪和小型机械臂" }, { name: "云台/航模", note: "飞行控制、云台与模型机构" }, { name: "工业设备", note: "控制机构与定制传动组件" }, ]; const PRIMARY_CATALOG_CATEGORIES = [ "标准舵机", "异形舵机", "全金属舵机", "大扭矩舵机", "积木舵机", "标准齿轮箱", "翻盖电机", "舵盘配件", ]; const CATALOG_FILTER_CATEGORIES = ["全部产品", ...PRIMARY_CATALOG_CATEGORIES, "静音舵机"]; const FEATURED_SPEC_ORDER = [ "堵转扭矩", "额定电压", "电压范围", "可操作角度", "信号类型", "通讯方式", "齿轮比", "寿命", "花键大小", "类别", "材质", "重量", "产品尺寸", "外形尺寸", ]; function getFeaturedSpecs(product: CatalogProduct) { return FEATURED_SPEC_ORDER .filter((label) => product.specs[label]) .slice(0, 4) .map((label) => [label, product.specs[label]] as const); } const PRECISION_GEAR_CARD_SPEC_ORDER = [ "模数", "齿数", "材质", "外径(mm)", "长(mm)", "宽(mm)", "高(mm)", "孔径", "类别", ]; function getPrecisionGearCardSpecs(product: CatalogProduct) { return PRECISION_GEAR_CARD_SPEC_ORDER .filter((label) => product.specs[label]) .slice(0, 2) .map((label) => [label, product.specs[label]] as const); } const SERVO_CATEGORIES = ["标准舵机", "异形舵机", "全金属舵机", "大扭矩舵机", "积木舵机"]; const SILENT_SERVO_CATEGORY = "静音舵机"; const CUSTOM_SERVO_CATEGORY = "非标准舵机定制"; const TORQUE_MIN = 0.09; const TORQUE_MAX = 920; const TORQUE_PRESETS = [0.1, 1, 5, 20, 100, 300, 920]; const APPLICATION_CATEGORY_MAP: Record = { AI毛绒: ["标准舵机", "异形舵机"], 智能玩具: ["标准舵机", "异形舵机", "积木舵机"], "教育/积木": ["积木舵机", "标准舵机"], 微型机器人: ["标准舵机", "异形舵机", "全金属舵机"], "云台/航模": ["标准舵机", "全金属舵机"], 工业设备: ["全金属舵机", "大扭矩舵机", "异形舵机"], }; function parseNumbers(value = "") { return (value.match(/\d+(?:\.\d+)?/g) ?? []).map(Number); } function isSilentServo(product: CatalogProduct) { return SERVO_CATEGORIES.includes(product.category) && [product.name, ...Object.values(product.specs)].join(" ").includes("静音"); } const SILENT_SERVO_COUNT = CATALOG_PRODUCTS.filter(isSilentServo).length; function getCategoryCount(category: string) { return category === SILENT_SERVO_CATEGORY ? SILENT_SERVO_COUNT : category === "舵盘配件" ? STEERING_WHEEL_ACCESSORIES.length : CATALOG_COUNTS[category]; } type ProductScale = { key: string; label: string; unit: "g" | "kg"; value: number; }; function getProductScale(product: CatalogProduct): ProductScale | null { const kilogramMatch = product.name.match(/(\d+(?:\.\d+)?)\s*(?:kg|千克)/i); if (kilogramMatch) { const value = Number(kilogramMatch[1]); return { key: `kg:${value}`, label: `${value}kg`, unit: "kg", value }; } const gramMatch = product.name.match(/(\d+(?:\.\d+)?)\s*(?:g|克)/i); if (gramMatch) { const value = Number(gramMatch[1]); return { key: `g:${value}`, label: `${value}g`, unit: "g", value }; } return null; } const PRODUCT_SCALE_OPTIONS = [...CATALOG_PRODUCTS.reduce((options, product) => { const scale = getProductScale(product); if (!scale) return options; const current = options.get(scale.key); options.set(scale.key, { ...scale, count: (current?.count ?? 0) + 1 }); return options; }, new Map()).values()].sort( (a, b) => (a.unit === b.unit ? a.value - b.value : a.unit === "g" ? -1 : 1), ); const GRAM_SCALE_OPTIONS = PRODUCT_SCALE_OPTIONS.filter((option) => option.unit === "g"); const KILOGRAM_SCALE_OPTIONS = PRODUCT_SCALE_OPTIONS.filter((option) => option.unit === "kg"); const UNMARKED_SCALE_COUNT = CATALOG_PRODUCTS.length - PRODUCT_SCALE_OPTIONS.reduce( (total, option) => total + option.count, 0, ); function inferWeight(product: CatalogProduct) { const listedWeight = parseNumbers(product.specs["重量"])[0]; if (Number.isFinite(listedWeight)) return listedWeight; if (product.category !== "标准舵机") return null; const namedWeight = product.name.match(/(\d+(?:\.\d+)?)\s*(?:g|克)/i); return namedWeight ? Number(namedWeight[1]) : null; } function classifyGear(value = "") { const hasPlastic = /塑胶|塑料/.test(value); const hasMetal = /金属|铜|粉末冶金|滚齿/.test(value); if (hasPlastic && hasMetal) return "混合齿"; if (hasMetal) return "全金属齿"; if (hasPlastic) return "塑胶齿"; return "未标注"; } function controlMatches(control: string, productControl: string) { if (control === "不限") return true; const value = productControl.toUpperCase(); if (control === "PWM/三线") return value.includes("PWM") || /3线|3PIN/.test(value); if (control === "串口/TTL") return value.includes("TTL") || value.includes("串口"); if (control === "二/四/五线") return /[245]线|[245]PIN/.test(value); return value.includes(control.toUpperCase()); } function angleMatches(angle: string, productAngle: string) { if (angle === "不限") return true; if (!productAngle) return false; const firstAngle = parseNumbers(productAngle)[0] ?? 0; if (angle === "135°") return firstAngle > 0 && firstAngle <= 135; if (angle === "180°") return firstAngle > 135 && firstAngle <= 180; if (angle === "270–330°") return firstAngle >= 270 && firstAngle < 360; return productAngle.includes("多圈") || firstAngle >= 360; } function formatTorque(value: number) { if (value < 1) return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); if (value < 100) return value.toFixed(1).replace(/\.0$/, ""); return Math.round(value).toString(); } function torqueToSlider(value: number) { return ((Math.log(value) - Math.log(TORQUE_MIN)) / (Math.log(TORQUE_MAX) - Math.log(TORQUE_MIN))) * 100; } function sliderToTorque(position: number) { const value = Math.exp( Math.log(TORQUE_MIN) + (position / 100) * (Math.log(TORQUE_MAX) - Math.log(TORQUE_MIN)), ); if (value < 1) return Number(value.toFixed(2)); if (value < 100) return Number(value.toFixed(1)); return Math.round(value); } const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) => SERVO_CATEGORIES.includes(product.category), ).map((product) => { const voltageValues = parseNumbers(product.specs["电压范围"]); const ratedVoltage = product.ratedVoltage ?? 0; 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, voltageMin: voltageValues[0] ?? ratedVoltage, voltageMax: voltageValues.at(-1) ?? ratedVoltage, weight: inferWeight(product), isSilent: isSilentServo(product), gear: classifyGear(product.specs["齿轮材质"]), angle: product.specs["可操作角度"] ?? product.specs["操作角度范围"] ?? "", controlSearch, controlProtocol, terminalConnection, speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "", size: product.specs["产品尺寸"] ?? product.specs["外形尺寸"] ?? "", }; }); const RATED_VOLTAGES = [...new Set( SELECTOR_PRODUCTS .map((product) => product.ratedVoltage) .filter((voltage): voltage is number => voltage !== null), )].sort((a, b) => a - b); const DEFAULT_REQUIREMENTS: Requirements = { application: "", category: "全部舵机", torque: 2, voltage: 5, weightLimit: 0, angle: "不限", control: "不限", gear: "不限", annualVolume: "5000–50000件", firstOrderQuantity: "", currentModel: "", otherRequirements: "", painPoint: "交期", }; function getMatches(requirements: Requirements) { return SELECTOR_PRODUCTS.map((product) => { let score = 20; const reasons: string[] = []; const categoryOk = requirements.category === "全部舵机" || requirements.category === product.category || (requirements.category === SILENT_SERVO_CATEGORY && product.isSilent); const torqueOk = product.torque >= requirements.torque; const voltageOk = requirements.voltage === product.ratedVoltage; const weightOk = 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.controlSearch); const coreCompatible = categoryOk && torqueOk && voltageOk && weightOk && gearOk && angleOk && controlOk; if (categoryOk) score += requirements.category === "全部舵机" ? 6 : 16; else score -= 120; if (APPLICATION_CATEGORY_MAP[requirements.application]?.includes(product.category)) { score += 12; reasons.push(`适合${requirements.application}场景`); } if (torqueOk) { const ratio = product.torque / requirements.torque; score += Math.max(5, 28 - Math.log10(Math.max(1, ratio)) * 9); const margin = Math.round((product.torque / requirements.torque - 1) * 100); reasons.push(`扭矩满足,约有${Math.max(0, margin)}%余量`); } else { score -= 120; } if (voltageOk) { score += 22; reasons.push(`${requirements.voltage}V额定电压匹配`); } else { score -= 90; } if (requirements.weightLimit > 0) { if (product.weight === null) score -= 6; else if (weightOk) { score += 10; reasons.push(`重量${product.weight}g,符合上限`); } else score -= 45; } if (requirements.gear !== "不限") score += gearOk ? 10 : -40; if (requirements.angle !== "不限") score += angleOk ? 10 : -40; if (requirements.control !== "不限") score += controlOk ? 12 : -50; if (requirements.painPoint === "成本" && product.gear === "塑料齿") score += 8; if (requirements.painPoint === "耐久" && product.gear === "全金属齿") score += 8; if (requirements.painPoint === "体积" && product.weight !== null && product.weight <= 9) score += 8; return { product, score, coreCompatible, confidence: Math.max(48, Math.min(97, Math.round(score))), reasons: reasons.slice(0, 3), }; }).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 (
{label} {value}
); } type CartItem = { product: CatalogProduct; quantity: number; source: string; }; const CART_STORAGE_KEY = "gaudelo-product-cart"; type CartExportImage = { base64: string; extension: "jpeg"; }; 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(); const pad = (value: number) => String(value).padStart(2, "0"); const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; return `高徳乐产品清单_${date}.xlsx`; } function getCartPdfFilename() { return getCartExportFilename().replace(/\.xlsx$/, ".pdf"); } function getFullSpecEntries(product: CatalogProduct) { return Object.entries(product.specs).filter(([, value]) => value); } function getKeySpecText(product: CatalogProduct) { const operationAngle = product.specs["可操作角度"] ?? product.specs["操作角度范围"]; return [ ...getFeaturedSpecs(product), ...(operationAngle ? [["可操作角度", operationAngle] as const] : []), ] .map(([label, value]) => `${label}:${value}`) .join("\n") || "暂无主要参数"; } function getFullSpecText(product: CatalogProduct) { return getFullSpecEntries(product) .map(([label, value]) => `${label}:${value}`) .join("\n") || "暂无详细参数"; } function compressCartImage(src: string) { return new Promise((resolve) => { const image = new Image(); image.onload = () => { const canvas = document.createElement("canvas"); canvas.width = CART_IMAGE_SIZE_PX; canvas.height = CART_IMAGE_SIZE_PX; const context = canvas.getContext("2d"); if (!context) { resolve(null); return; } context.fillStyle = "#ffffff"; context.fillRect(0, 0, canvas.width, canvas.height); const padding = 10; const scale = Math.min( (canvas.width - padding * 2) / image.naturalWidth, (canvas.height - padding * 2) / image.naturalHeight, ); const imageWidth = image.naturalWidth * scale; const imageHeight = image.naturalHeight * scale; context.drawImage( image, (canvas.width - imageWidth) / 2, (canvas.height - imageHeight) / 2, imageWidth, imageHeight, ); resolve({ base64: canvas.toDataURL("image/jpeg", 0.78), extension: "jpeg", }); }; image.onerror = () => resolve(null); image.src = src; }); } async function buildCartWorkbook(cartItems: CartItem[]) { const { default: ExcelJS } = await import("exceljs"); const workbook = new ExcelJS.Workbook(); workbook.creator = "高徳乐 AI 舵机选型助手"; workbook.created = new Date(); const summarySheet = workbook.addWorksheet("产品清单"); const detailsSheet = workbook.addWorksheet("详细参数"); summarySheet.views = [{ state: "frozen", ySplit: 1 }]; detailsSheet.views = [{ state: "frozen", ySplit: 1 }]; summarySheet.columns = [ { header: "产品图片", key: "image", width: 36 }, { header: "产品名称", key: "name", width: 34 }, { header: "产品编码", key: "code", width: 24 }, { header: "产品分类", key: "category", width: 18 }, { header: "购买数量", key: "quantity", width: 12 }, { header: "详细参数", key: "detailSpecs", width: 48 }, ]; detailsSheet.columns = [ { header: "产品名称", key: "name", width: 34 }, { header: "产品编码", key: "code", width: 24 }, { header: "产品分类", key: "category", width: 18 }, { header: "购买数量", key: "quantity", width: 12 }, { header: "参数名称", key: "label", width: 22 }, { header: "参数值", key: "value", width: 48 }, ]; const headerStyle = { font: { bold: true, color: { argb: "FFFFFFFF" } }, fill: { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FF185EE8" } }, alignment: { vertical: "middle" as const, horizontal: "center" as const, wrapText: true }, }; summarySheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); }); detailsSheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); }); summarySheet.getRow(1).height = 26; detailsSheet.getRow(1).height = 26; const imageCache = new Map>(); const getImageAsset = (src: string) => { const cached = imageCache.get(src); if (cached) return cached; const pending = compressCartImage(src); imageCache.set(src, pending); return pending; }; for (const item of cartItems) { const product = item.product; const specEntries = getFullSpecEntries(product); const summaryRow = summarySheet.addRow({ image: product.image ? "" : "暂无图片", name: product.name, code: product.code, category: product.category, quantity: item.quantity, detailSpecs: getFullSpecText(product), }); summaryRow.height = Math.max(142, Math.min(240, 24 + specEntries.length * 15)); summaryRow.eachCell((cell) => { cell.alignment = { vertical: "middle", wrapText: true }; cell.border = { top: { style: "thin", color: { argb: "FFDDE6F3" } }, bottom: { style: "thin", color: { argb: "FFDDE6F3" } }, left: { style: "thin", color: { argb: "FFDDE6F3" } }, right: { style: "thin", color: { argb: "FFDDE6F3" } }, }; }); if (product.image) { const imageAsset = await getImageAsset(product.image); if (imageAsset) { const imageId = workbook.addImage(imageAsset); summarySheet.addImage(imageId, { 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; } else { summaryRow.getCell(1).value = "图片加载失败"; } } for (const [label, value] of specEntries.length > 0 ? specEntries : [["暂无参数", ""]]) { const detailRow = detailsSheet.addRow({ name: product.name, code: product.code, category: product.category, quantity: item.quantity, label, value, }); detailRow.height = Math.max(24, Math.min(60, 18 + Math.ceil(String(value).length / 28) * 15)); detailRow.eachCell((cell) => { cell.alignment = { vertical: "middle", wrapText: true }; cell.border = { top: { style: "thin", color: { argb: "FFE5EBF3" } }, bottom: { style: "thin", color: { argb: "FFE5EBF3" } }, left: { style: "thin", color: { argb: "FFE5EBF3" } }, right: { style: "thin", color: { argb: "FFE5EBF3" } }, }; }); } } summarySheet.autoFilter = { from: "A1", to: `F${Math.max(1, summarySheet.rowCount)}` }; detailsSheet.autoFilter = { from: "A1", to: `F${Math.max(1, detailsSheet.rowCount)}` }; return workbook; } function downloadFile(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); } function isMobileDevice() { return /Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent) || (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; 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); const [requirements, setRequirements] = useState(DEFAULT_REQUIREMENTS); const [showSampleForm, setShowSampleForm] = useState(false); const [submitted, setSubmitted] = useState(false); const [leadId, setLeadId] = useState(""); const [catalogQuery, setCatalogQuery] = useState(""); const [catalogCategory, setCatalogCategory] = useState("全部产品"); const [catalogScale, setCatalogScale] = useState("all"); const [catalogLimit, setCatalogLimit] = useState(24); const [catalogToolbarCompact, setCatalogToolbarCompact] = useState(false); const [catalogToolbarFiltersOpen, setCatalogToolbarFiltersOpen] = useState(false); const [catalogToolbarHidden, setCatalogToolbarHidden] = useState(false); const [precisionGearQuery, setPrecisionGearQuery] = useState(""); const [precisionGearCategory, setPrecisionGearCategory] = useState("全部子类"); const [precisionGearLimit, setPrecisionGearLimit] = useState(24); const [precisionGearToolbarCompact, setPrecisionGearToolbarCompact] = useState(false); const [precisionGearFiltersOpen, setPrecisionGearFiltersOpen] = useState(false); const [selectedCatalogProduct, setSelectedCatalogProduct] = useState(null); const [catalogVisualMode, setCatalogVisualMode] = useState("main"); const [cartItems, setCartItems] = useState(() => { if (typeof window === "undefined") return []; try { const storedCart = window.localStorage.getItem(CART_STORAGE_KEY); const parsedCart = storedCart ? JSON.parse(storedCart) as CartItem[] : []; return Array.isArray(parsedCart) ? parsedCart : []; } catch { return []; } }); 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(""); const matches = useMemo(() => getMatches(requirements), [requirements]); const topMatches = matches.slice(0, 3); const top = topMatches[0]; const compatibleCount = matches.filter((match) => match.coreCompatible).length; const filteredCatalog = useMemo(() => { const query = catalogQuery.trim().toLowerCase(); return CATALOG_PRODUCTS.filter((product) => { const productScale = getProductScale(product)?.key ?? "unmarked"; if (catalogScale !== "all" && productScale !== catalogScale) return false; const matchesCategory = catalogCategory === "全部产品" || product.category === catalogCategory || (catalogCategory === SILENT_SERVO_CATEGORY && isSilentServo(product)); if (!matchesCategory) return false; if (!query) return true; const haystack = [ product.name, product.code, product.category, ...Object.keys(product.specs), ...Object.values(product.specs), ] .join(" ") .toLowerCase(); return haystack.includes(query); }); }, [catalogCategory, catalogQuery, catalogScale]); const visibleCatalog = filteredCatalog.slice(0, catalogLimit); const filteredPrecisionGears = useMemo(() => { const query = precisionGearQuery.trim().toLowerCase(); return PRECISION_GEAR_PRODUCTS.filter((product) => { if (precisionGearCategory !== "全部子类" && product.subCategory !== precisionGearCategory) return false; if (!query) return true; const haystack = [ product.name, product.code, product.subCategory, ...Object.keys(product.specs), ...Object.values(product.specs), ].join(" ").toLowerCase(); return haystack.includes(query); }); }, [precisionGearCategory, precisionGearQuery]); const visiblePrecisionGears = filteredPrecisionGears.slice(0, precisionGearLimit); const needsEngineer = !top?.coreCompatible || requirements.annualVolume === "50000件以上" || Number(requirements.firstOrderQuantity) >= 1000 || requirements.application === "工业设备"; const selectedCatalogDimensionImage = selectedCatalogProduct ? getCatalogProductDimensionImage(selectedCatalogProduct) : null; const cartCount = cartItems.reduce((total, item) => total + item.quantity, 0); useEffect(() => { function updateCatalogToolbarState() { const toolbar = document.querySelector(".catalog-toolbar"); const shouldCompact = window.innerWidth <= 720 && Boolean(toolbar) && toolbar.getBoundingClientRect().top <= 72 && window.scrollY > 0; setCatalogToolbarCompact((current) => (current === shouldCompact ? current : shouldCompact)); if (!shouldCompact) setCatalogToolbarFiltersOpen(false); const precisionHeading = document.querySelector(".precision-gear-catalog-heading"); const shouldHideCatalogToolbar = window.innerWidth <= 720 && Boolean(precisionHeading) && precisionHeading.getBoundingClientRect().top <= 72; setCatalogToolbarHidden((current) => (current === shouldHideCatalogToolbar ? current : shouldHideCatalogToolbar)); const precisionToolbar = document.querySelector(".precision-gear-controls"); const shouldCompactPrecision = window.innerWidth <= 720 && Boolean(precisionToolbar) && precisionToolbar.getBoundingClientRect().top <= 72 && window.scrollY > 0; setPrecisionGearToolbarCompact((current) => (current === shouldCompactPrecision ? current : shouldCompactPrecision)); if (!shouldCompactPrecision) setPrecisionGearFiltersOpen(false); } updateCatalogToolbarState(); window.addEventListener("scroll", updateCatalogToolbarState, { passive: true }); document.addEventListener("scroll", updateCatalogToolbarState, { passive: true, capture: true }); window.addEventListener("resize", updateCatalogToolbarState); return () => { window.removeEventListener("scroll", updateCatalogToolbarState); document.removeEventListener("scroll", updateCatalogToolbarState, true); window.removeEventListener("resize", updateCatalogToolbarState); }; }, []); useEffect(() => { try { window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartItems)); } catch (error) { console.warn("保存购物车失败", error); } }, [cartItems]); function openCatalogProduct(product: CatalogProduct) { setSelectedCatalogProduct(product); setCatalogVisualMode("main"); } function addToCart(product: CatalogProduct, source = product.category) { setCartItems((current) => { const existingItem = current.find((item) => item.product.id === product.id); if (existingItem) { return current.map((item) => item.product.id === product.id ? { ...item, quantity: item.quantity + 1, source } : item); } return [...current, { product, quantity: 1, source }]; }); setCartMessage(`${product.name} 已加入购物车`); setCartOpen(true); window.setTimeout(() => setCartMessage(""), 2200); } function requestSpecSheet(product: CatalogProduct) { setCartMessage(`${product.name} 的产品规格书资料待补充`); window.setTimeout(() => setCartMessage(""), 2400); } function addTopMatchesToCart() { if (topMatches.length === 0) return; setCartItems((current) => { const next = [...current]; topMatches.forEach((match) => { const existingIndex = next.findIndex((item) => item.product.id === match.product.id); if (existingIndex >= 0) { const existingItem = next[existingIndex]; next[existingIndex] = { ...existingItem, quantity: existingItem.quantity + 1, source: "AI初选结果", }; } else { next.push({ product: match.product, quantity: 1, source: "AI初选结果" }); } }); return next; }); setCartMessage(`${topMatches.length} 款 AI 推荐型号已加入购物车`); setCartOpen(true); window.setTimeout(() => setCartMessage(""), 2400); } function updateCartQuantity(productId: string, quantity: number) { if (quantity <= 0) { setCartItems((current) => current.filter((item) => item.product.id !== productId)); return; } setCartItems((current) => current.map((item) => item.product.id === productId ? { ...item, quantity } : item)); } function clearCart() { setCartItems([]); setCartSampleFormOpen(false); setCartSampleSubmitted(false); } function openCartSampleForm() { if (cartItems.length === 0) return; setCartSampleSubmitted(false); setCartSampleFormOpen(true); } function submitCartSampleRequest(event: FormEvent) { event.preventDefault(); const id = `GDL-S-${new Date().toISOString().slice(5, 10).replace("-", "")}-${String(Date.now()).slice(-4)}`; setCartSampleLeadId(id); setCartSampleSubmitted(true); } async function saveCartList() { if (cartItems.length === 0 || exportingCart) return; setExportingCart(true); setCartMessage("正在生成产品清单…"); try { const workbook = await buildCartWorkbook(cartItems); const buffer = await workbook.xlsx.writeBuffer(); const filename = getCartExportFilename(); const file = new File([buffer as BlobPart], filename, { type: CART_EXPORT_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: "购物车产品清单", files: [file], }); setCartMessage("已打开系统分享面板"); return; } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; } } } downloadFile(file, filename); setCartMessage("产品清单已下载"); } catch (error) { console.error("生成购物车清单失败", error); setCartMessage("产品清单生成失败,请稍后重试"); } finally { setExportingCart(false); window.setTimeout(() => setCartMessage(""), 2400); } } 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() { setMobileMenuOpen(false); setStage(1); requestAnimationFrame(() => { document.getElementById("selector")?.scrollIntoView({ behavior: "smooth", block: "start" }); }); } function update(key: K, value: Requirements[K]) { setRequirements((current) => ({ ...current, [key]: value })); } function reset() { setStage(1); setRequirements(DEFAULT_REQUIREMENTS); setShowSampleForm(false); setSubmitted(false); } function submitLead(event: FormEvent) { event.preventDefault(); const id = `GDL-${new Date().toISOString().slice(5, 10).replace("-", "")}-${String( Date.now(), ).slice(-4)}`; setLeadId(id); setSubmitted(true); } return (
{cartMessage && (
{cartMessage}
)} {cartOpen && (
{ if (event.target === event.currentTarget) setCartOpen(false); }} >