Improve catalog cards and cart exports
@@ -118,6 +118,18 @@ export function getCatalogProductImage(product: CatalogProduct) {
|
|||||||
return product.image;
|
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) {
|
export function getCatalogProductDimensionImage(product: CatalogProduct) {
|
||||||
if (product.category === "精密齿轮配件") {
|
if (product.category === "精密齿轮配件") {
|
||||||
return product.dimensionImage ?? null;
|
return product.dimensionImage ?? null;
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
|||||||
.metric span { color: #8994a6; font-size: 8px; }
|
.metric span { color: #8994a6; font-size: 8px; }
|
||||||
.metric strong { font-size: 10px; }
|
.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; }
|
.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 { 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 span { color: #7c8798; font-size: 9px; }
|
||||||
.price-line strong { color: #e55726; font-size: 20px; }
|
.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-placeholder { color: #9eb3d3; font-size: 14px; font-weight: 800; letter-spacing: .12em; }
|
||||||
.catalog-card-body { padding: 16px; }
|
.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 > 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 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(3, minmax(0, 1fr)); gap: 8px; border-radius: 9px; background: #f6f8fb; }
|
.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 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 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 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; }
|
.catalog-card-body > .add-cart-button { margin-top: 5px; }
|
||||||
.precision-gear-card-body .add-cart-button { margin-top: 10px; }
|
.precision-gear-card-body .add-cart-button { margin-top: 10px; }
|
||||||
.recommendation-content .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-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-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); }
|
.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 { display: flex; align-items: center; gap: 18px; margin-top: 20px; }
|
||||||
.catalog-modal-actions .panel-primary { flex: 0 0 auto; }
|
.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 .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; }
|
.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); }
|
.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; }
|
.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-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
|
||||||
.precision-gear-card-body { padding: 9px; }
|
.precision-gear-card-body { padding: 9px; }
|
||||||
.catalog-card-body { padding: 15px; }
|
.catalog-card-body { padding: 15px; }
|
||||||
.catalog-card-body h3 { height: auto; min-height: 42px; font-size: 14px; }
|
.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(3, minmax(0, 1fr)); gap: 6px; padding: 9px 8px; }
|
.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 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 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-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) {
|
@media (max-width: 380px) {
|
||||||
.catalog-spec-table { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.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 {
|
@media print {
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
metadataBase,
|
metadataBase,
|
||||||
title: "高徳乐 AI 舵机选型助手",
|
title: "高徳乐 AI 舵机选型助手",
|
||||||
description: "高徳乐280个产品型号完整目录,支持搜索筛选、AI舵机初选与样品申请。",
|
description: "高徳乐243款舵机+配件,共286个产品型号,支持搜索筛选、AI舵机初选与样品申请。",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "高徳乐 AI 舵机选型助手",
|
title: "高徳乐 AI 舵机选型助手",
|
||||||
description: "280个型号完整目录。3分钟找到适合你的舵机,AI初选,工程师复核。",
|
description: "243款舵机+配件,共286个型号。3分钟找到适合你的舵机,AI初选,工程师复核。",
|
||||||
images: [{ url: new URL("/og.png", metadataBase).toString(), width: 1200, height: 630 }],
|
images: [{ url: new URL("/og.png", metadataBase).toString(), width: 1200, height: 630 }],
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
|
|||||||
527
app/page.tsx
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "./catalog-data";
|
} from "./catalog-data";
|
||||||
import { STEERING_WHEEL_ACCESSORIES } from "./catalog-accessories";
|
import { STEERING_WHEEL_ACCESSORIES } from "./catalog-accessories";
|
||||||
import { PRECISION_GEAR_PRODUCTS, PRECISION_GEAR_SUBCATEGORIES } from "./precision-gear-data";
|
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) => ({
|
const CATALOG_PRODUCTS = [...RAW_CATALOG_PRODUCTS, ...STEERING_WHEEL_ACCESSORIES].map((product) => ({
|
||||||
...product,
|
...product,
|
||||||
@@ -59,6 +59,7 @@ const FEATURED_SPEC_ORDER = [
|
|||||||
"堵转扭矩",
|
"堵转扭矩",
|
||||||
"额定电压",
|
"额定电压",
|
||||||
"电压范围",
|
"电压范围",
|
||||||
|
"可操作角度",
|
||||||
"信号类型",
|
"信号类型",
|
||||||
"通讯方式",
|
"通讯方式",
|
||||||
"齿轮比",
|
"齿轮比",
|
||||||
@@ -74,7 +75,7 @@ const FEATURED_SPEC_ORDER = [
|
|||||||
function getFeaturedSpecs(product: CatalogProduct) {
|
function getFeaturedSpecs(product: CatalogProduct) {
|
||||||
return FEATURED_SPEC_ORDER
|
return FEATURED_SPEC_ORDER
|
||||||
.filter((label) => product.specs[label])
|
.filter((label) => product.specs[label])
|
||||||
.slice(0, 3)
|
.slice(0, 4)
|
||||||
.map((label) => [label, product.specs[label]] as const);
|
.map((label) => [label, product.specs[label]] as const);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,12 +233,16 @@ const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) =>
|
|||||||
).map((product) => {
|
).map((product) => {
|
||||||
const voltageValues = parseNumbers(product.specs["电压范围"]);
|
const voltageValues = parseNumbers(product.specs["电压范围"]);
|
||||||
const ratedVoltage = product.ratedVoltage ?? 0;
|
const ratedVoltage = product.ratedVoltage ?? 0;
|
||||||
const control = [
|
const controlSearch = [
|
||||||
product.specs["信号类型"],
|
product.specs["信号类型"],
|
||||||
product.specs["通讯方式"],
|
product.specs["通讯方式"],
|
||||||
product.specs["端子连接线"],
|
product.specs["端子连接线"],
|
||||||
product.specs["连接线类型"],
|
product.specs["连接线类型"],
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
|
const controlProtocol = [product.specs["信号类型"], product.specs["通讯方式"]]
|
||||||
|
.filter(Boolean).join(" · ") || "目录未标注";
|
||||||
|
const terminalConnection = [product.specs["端子连接线"], product.specs["连接线类型"]]
|
||||||
|
.filter(Boolean).join(" · ") || "目录未标注";
|
||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
torque: product.torqueValue ?? 0,
|
torque: product.torqueValue ?? 0,
|
||||||
@@ -247,7 +252,9 @@ const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) =>
|
|||||||
isSilent: isSilentServo(product),
|
isSilent: isSilentServo(product),
|
||||||
gear: classifyGear(product.specs["齿轮材质"]),
|
gear: classifyGear(product.specs["齿轮材质"]),
|
||||||
angle: product.specs["可操作角度"] ?? product.specs["操作角度范围"] ?? "",
|
angle: product.specs["可操作角度"] ?? product.specs["操作角度范围"] ?? "",
|
||||||
control,
|
controlSearch,
|
||||||
|
controlProtocol,
|
||||||
|
terminalConnection,
|
||||||
speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "",
|
speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "",
|
||||||
size: 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;
|
requirements.weightLimit === 0 || product.weight === null || product.weight <= requirements.weightLimit;
|
||||||
const gearOk = requirements.gear === "不限" || requirements.gear === product.gear;
|
const gearOk = requirements.gear === "不限" || requirements.gear === product.gear;
|
||||||
const angleOk = angleMatches(requirements.angle, product.angle);
|
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;
|
const coreCompatible = categoryOk && torqueOk && voltageOk && weightOk && gearOk && angleOk && controlOk;
|
||||||
|
|
||||||
if (categoryOk) score += requirements.category === "全部舵机" ? 6 : 16;
|
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);
|
}).sort((a, b) => Number(b.coreCompatible) - Number(a.coreCompatible) || b.score - a.score);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SelectorMatch = ReturnType<typeof getMatches>[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 }) {
|
function Metric({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="metric">
|
<div className="metric">
|
||||||
@@ -365,6 +403,9 @@ type CartExportImage = {
|
|||||||
|
|
||||||
const CART_EXPORT_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
const CART_EXPORT_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
const CART_IMAGE_SIZE_PX = 189; // 约 5 cm,按 96 DPI 换算
|
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() {
|
function getCartExportFilename() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -373,6 +414,10 @@ function getCartExportFilename() {
|
|||||||
return `高徳乐产品清单_${date}.xlsx`;
|
return `高徳乐产品清单_${date}.xlsx`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCartPdfFilename() {
|
||||||
|
return getCartExportFilename().replace(/\.xlsx$/, ".pdf");
|
||||||
|
}
|
||||||
|
|
||||||
function getFullSpecEntries(product: CatalogProduct) {
|
function getFullSpecEntries(product: CatalogProduct) {
|
||||||
return Object.entries(product.specs).filter(([, value]) => value);
|
return Object.entries(product.specs).filter(([, value]) => value);
|
||||||
}
|
}
|
||||||
@@ -442,7 +487,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) {
|
|||||||
summarySheet.views = [{ state: "frozen", ySplit: 1 }];
|
summarySheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||||
detailsSheet.views = [{ state: "frozen", ySplit: 1 }];
|
detailsSheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||||
summarySheet.columns = [
|
summarySheet.columns = [
|
||||||
{ header: "产品图片", key: "image", width: 27 },
|
{ header: "产品图片", key: "image", width: 36 },
|
||||||
{ header: "产品名称", key: "name", width: 34 },
|
{ header: "产品名称", key: "name", width: 34 },
|
||||||
{ header: "产品编码", key: "code", width: 24 },
|
{ header: "产品编码", key: "code", width: 24 },
|
||||||
{ header: "产品分类", key: "category", width: 18 },
|
{ header: "产品分类", key: "category", width: 18 },
|
||||||
@@ -507,7 +552,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) {
|
|||||||
if (imageAsset) {
|
if (imageAsset) {
|
||||||
const imageId = workbook.addImage(imageAsset);
|
const imageId = workbook.addImage(imageAsset);
|
||||||
summarySheet.addImage(imageId, {
|
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 },
|
ext: { width: CART_IMAGE_SIZE_PX, height: CART_IMAGE_SIZE_PX },
|
||||||
});
|
});
|
||||||
summaryRow.getCell(1).value = null;
|
summaryRow.getCell(1).value = null;
|
||||||
@@ -543,7 +588,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) {
|
|||||||
return workbook;
|
return workbook;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadCartWorkbook(blob: Blob, filename: string) {
|
function downloadFile(blob: Blob, filename: string) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -559,6 +604,330 @@ function isMobileDevice() {
|
|||||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NativePdfImage = {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
height: number;
|
||||||
|
name: string;
|
||||||
|
width: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativePdfPage = {
|
||||||
|
images: Set<string>;
|
||||||
|
parts: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NativePdfDocument = {
|
||||||
|
images: Map<string, NativePdfImage>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, NativePdfImage>) {
|
||||||
|
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 = {
|
type SaveProductRequest = {
|
||||||
product: CatalogProduct;
|
product: CatalogProduct;
|
||||||
@@ -782,6 +1151,7 @@ export default function Home() {
|
|||||||
const [cartOpen, setCartOpen] = useState(false);
|
const [cartOpen, setCartOpen] = useState(false);
|
||||||
const [cartMessage, setCartMessage] = useState("");
|
const [cartMessage, setCartMessage] = useState("");
|
||||||
const [exportingCart, setExportingCart] = useState(false);
|
const [exportingCart, setExportingCart] = useState(false);
|
||||||
|
const [exportingPdf, setExportingPdf] = useState(false);
|
||||||
const [cartSampleFormOpen, setCartSampleFormOpen] = useState(false);
|
const [cartSampleFormOpen, setCartSampleFormOpen] = useState(false);
|
||||||
const [cartSampleSubmitted, setCartSampleSubmitted] = useState(false);
|
const [cartSampleSubmitted, setCartSampleSubmitted] = useState(false);
|
||||||
const [cartSampleLeadId, setCartSampleLeadId] = useState("");
|
const [cartSampleLeadId, setCartSampleLeadId] = useState("");
|
||||||
@@ -896,6 +1266,11 @@ export default function Home() {
|
|||||||
window.setTimeout(() => setCartMessage(""), 2200);
|
window.setTimeout(() => setCartMessage(""), 2200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestSpecSheet(product: CatalogProduct) {
|
||||||
|
setCartMessage(`${product.name} 的产品规格书资料待补充`);
|
||||||
|
window.setTimeout(() => setCartMessage(""), 2400);
|
||||||
|
}
|
||||||
|
|
||||||
function addTopMatchesToCart() {
|
function addTopMatchesToCart() {
|
||||||
if (topMatches.length === 0) return;
|
if (topMatches.length === 0) return;
|
||||||
setCartItems((current) => {
|
setCartItems((current) => {
|
||||||
@@ -975,7 +1350,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadCartWorkbook(file, filename);
|
downloadFile(file, filename);
|
||||||
setCartMessage("产品清单已下载");
|
setCartMessage("产品清单已下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("生成购物车清单失败", error);
|
console.error("生成购物车清单失败", error);
|
||||||
@@ -986,24 +1361,42 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCartPdf() {
|
async function exportCartPdf() {
|
||||||
if (cartItems.length === 0) return;
|
if (cartItems.length === 0 || exportingPdf) return;
|
||||||
const previousTitle = document.title;
|
|
||||||
document.body.classList.add("printing-cart");
|
setExportingPdf(true);
|
||||||
document.title = getCartExportFilename().replace(/\.xlsx$/, ".pdf");
|
setCartMessage("正在生成 PDF…");
|
||||||
setCartOpen(false);
|
try {
|
||||||
let fallbackCleanupTimer = 0;
|
const blob = await buildCartPdf(cartItems);
|
||||||
const cleanupPrintMode = () => {
|
const filename = getCartPdfFilename();
|
||||||
window.clearTimeout(fallbackCleanupTimer);
|
const file = new File([blob], filename, { type: PDF_MIME });
|
||||||
window.removeEventListener("afterprint", cleanupPrintMode);
|
|
||||||
document.body.classList.remove("printing-cart");
|
if (isMobileDevice() && typeof navigator.share === "function") {
|
||||||
document.title = previousTitle;
|
const canShareFiles = typeof navigator.canShare !== "function" || navigator.canShare({ files: [file] });
|
||||||
};
|
if (canShareFiles) {
|
||||||
window.addEventListener("afterprint", cleanupPrintMode);
|
try {
|
||||||
fallbackCleanupTimer = window.setTimeout(cleanupPrintMode, 30000);
|
await navigator.share({
|
||||||
window.setTimeout(() => {
|
title: "高徳乐产品清单",
|
||||||
window.print();
|
text: "购物车产品清单 PDF",
|
||||||
}, 80);
|
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() {
|
function startSelection() {
|
||||||
@@ -1104,7 +1497,7 @@ export default function Home() {
|
|||||||
{cartItems.map((item) => (
|
{cartItems.map((item) => (
|
||||||
<div className="cart-item" key={item.product.id}>
|
<div className="cart-item" key={item.product.id}>
|
||||||
<div className="cart-item-image">
|
<div className="cart-item-image">
|
||||||
{item.product.image ? <img src={item.product.image} alt="" /> : <span>G</span>}
|
{item.product.image ? <img src={getCatalogProductThumbnail(item.product.image)} alt="" /> : <span>G</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="cart-item-copy">
|
<div className="cart-item-copy">
|
||||||
<small>{item.source}</small>
|
<small>{item.source}</small>
|
||||||
@@ -1139,10 +1532,10 @@ export default function Home() {
|
|||||||
className="outline-button cart-pdf-button"
|
className="outline-button cart-pdf-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={exportCartPdf}
|
onClick={exportCartPdf}
|
||||||
disabled={cartItems.length === 0}
|
disabled={cartItems.length === 0 || exportingPdf}
|
||||||
aria-label="导出购物车 PDF"
|
aria-label={exportingPdf ? "正在生成购物车 PDF" : "导出购物车 PDF"}
|
||||||
>
|
>
|
||||||
导出 PDF
|
{exportingPdf ? "生成中…" : "导出 PDF"}
|
||||||
</button>
|
</button>
|
||||||
<button className="panel-primary" type="button" onClick={() => { setCartOpen(false); startSelection(); }}>继续选型 <span>→</span></button>
|
<button className="panel-primary" type="button" onClick={() => { setCartOpen(false); startSelection(); }}>继续选型 <span>→</span></button>
|
||||||
<button className="panel-primary cart-sample-button" type="button" onClick={openCartSampleForm}>购买样品申请 <span>→</span></button>
|
<button className="panel-primary cart-sample-button" type="button" onClick={openCartSampleForm}>购买样品申请 <span>→</span></button>
|
||||||
@@ -1173,7 +1566,7 @@ export default function Home() {
|
|||||||
<h1>产品清单</h1>
|
<h1>产品清单</h1>
|
||||||
<span>购物车共 {cartItems.length} 个型号 / {cartCount} 件产品</span>
|
<span>购物车共 {cartItems.length} 个型号 / {cartCount} 件产品</span>
|
||||||
</div>
|
</div>
|
||||||
<strong>{getCartExportFilename().replace(/\.xlsx$/, ".pdf")}</strong>
|
<strong>{getCartPdfFilename()}</strong>
|
||||||
</div>
|
</div>
|
||||||
<table className="cart-print-summary">
|
<table className="cart-print-summary">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1227,10 +1620,10 @@ export default function Home() {
|
|||||||
<p className="cart-print-footer">高徳乐 AI 舵机选型助手 · 本清单用于客户沟通、选型确认和打印留档,最终参数以正式技术文件为准。</p>
|
<p className="cart-print-footer">高徳乐 AI 舵机选型助手 · 本清单用于客户沟通、选型确认和打印留档,最终参数以正式技术文件为准。</p>
|
||||||
</section>
|
</section>
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<a className="brand" href="#top" aria-label="高德乐 AI选型首页">
|
<a className="brand" href="#top" aria-label="高徳乐 AI选型首页">
|
||||||
<span className="brand-wordmark">
|
<span className="brand-wordmark">
|
||||||
<strong>GAUDELOT</strong>
|
<strong>GAUDELOT</strong>
|
||||||
<small>高德乐</small>
|
<small>高徳乐</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<nav id="mobile-primary-nav" className={mobileMenuOpen ? "is-open" : ""} aria-label="主导航">
|
<nav id="mobile-primary-nav" className={mobileMenuOpen ? "is-open" : ""} aria-label="主导航">
|
||||||
@@ -1279,7 +1672,7 @@ export default function Home() {
|
|||||||
<a href="#products" className="text-action">查看能力范围</a>
|
<a href="#products" className="text-action">查看能力范围</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="trust-row">
|
<div className="trust-row">
|
||||||
<span><b>280</b> 个产品型号</span>
|
<span><b>243</b> 款舵机+配件</span>
|
||||||
<span><b>0.09–920</b> kgf·cm</span>
|
<span><b>0.09–920</b> kgf·cm</span>
|
||||||
<span><b>48h</b> 方案响应</span>
|
<span><b>48h</b> 方案响应</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1449,7 +1842,7 @@ export default function Home() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="select-field">
|
<label className="select-field">
|
||||||
<span>控制 / 通讯方式</span>
|
<span>控制协议</span>
|
||||||
<select
|
<select
|
||||||
value={requirements.control}
|
value={requirements.control}
|
||||||
onChange={(event) => update("control", event.target.value)}
|
onChange={(event) => update("control", event.target.value)}
|
||||||
@@ -1582,7 +1975,7 @@ export default function Home() {
|
|||||||
<Metric label="最低堵转扭矩" value={`${formatTorque(requirements.torque)} kgf·cm`} />
|
<Metric label="最低堵转扭矩" value={`${formatTorque(requirements.torque)} kgf·cm`} />
|
||||||
<Metric label="额定电压" value={`${requirements.voltage.toFixed(1)}V`} />
|
<Metric label="额定电压" value={`${requirements.voltage.toFixed(1)}V`} />
|
||||||
<Metric label="操作角度" value={requirements.angle} />
|
<Metric label="操作角度" value={requirements.angle} />
|
||||||
<Metric label="控制 / 通讯" value={requirements.control} />
|
<Metric label="控制协议" value={requirements.control} />
|
||||||
<Metric label="首次采购数量" value={requirements.firstOrderQuantity ? `${requirements.firstOrderQuantity}只` : "待确认"} />
|
<Metric label="首次采购数量" value={requirements.firstOrderQuantity ? `${requirements.firstOrderQuantity}只` : "待确认"} />
|
||||||
<Metric label="年度用量" value={requirements.annualVolume} />
|
<Metric label="年度用量" value={requirements.annualVolume} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1653,7 +2046,7 @@ export default function Home() {
|
|||||||
onClick={() => openCatalogProduct(match.product)}
|
onClick={() => openCatalogProduct(match.product)}
|
||||||
aria-label={`查看${match.product.name}大图`}
|
aria-label={`查看${match.product.name}大图`}
|
||||||
>
|
>
|
||||||
<img src={match.product.image} alt={match.product.name} />
|
<img src={getCatalogProductThumbnail(match.product.image)} alt={match.product.name} />
|
||||||
<span className="match-badge">
|
<span className="match-badge">
|
||||||
{index === 0 ? (match.coreCompatible ? "首选推荐" : "待复核候选") : `${match.confidence}% 匹配`}
|
{index === 0 ? (match.coreCompatible ? "首选推荐" : "待复核候选") : `${match.confidence}% 匹配`}
|
||||||
</span>
|
</span>
|
||||||
@@ -1668,12 +2061,24 @@ export default function Home() {
|
|||||||
<Metric label="电压范围" value={`${match.product.voltageMin}–${match.product.voltageMax}V`} />
|
<Metric label="电压范围" value={`${match.product.voltageMin}–${match.product.voltageMax}V`} />
|
||||||
<Metric label="空载速度" value={match.product.speed || "目录未标注"} />
|
<Metric label="空载速度" value={match.product.speed || "目录未标注"} />
|
||||||
<Metric label="操作角度" value={match.product.angle || "目录未标注"} />
|
<Metric label="操作角度" value={match.product.angle || "目录未标注"} />
|
||||||
<Metric label="控制 / 通讯" value={match.product.control || "目录未标注"} />
|
<Metric label="控制协议" value={match.product.controlProtocol} />
|
||||||
|
<Metric label="端子 / 连接线" value={match.product.terminalConnection} />
|
||||||
<Metric label="齿轮材质" value={match.product.gear} />
|
<Metric label="齿轮材质" value={match.product.gear} />
|
||||||
</div>
|
</div>
|
||||||
<ul className="reason-list">
|
<div className="recommendation-insights">
|
||||||
{match.reasons.slice(0, 2).map((reason) => <li key={reason}>✓ {reason}</li>)}
|
<div>
|
||||||
</ul>
|
<strong>为什么推荐</strong>
|
||||||
|
<ul className="reason-list">
|
||||||
|
{match.reasons.length > 0
|
||||||
|
? match.reasons.slice(0, 2).map((reason) => <li key={reason}>✓ {reason}</li>)
|
||||||
|
: <li>✓ 满足当前核心参数筛选</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>与另外两款的主要区别</strong>
|
||||||
|
<p>{getRecommendationDifference(match, topMatches)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="source-line">
|
<div className="source-line">
|
||||||
<span>官方目录型号</span>
|
<span>官方目录型号</span>
|
||||||
<strong>{match.product.category}</strong>
|
<strong>{match.product.category}</strong>
|
||||||
@@ -1687,6 +2092,14 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
加入到购物车 <span>+</span>
|
加入到购物车 <span>+</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="spec-sheet-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => requestSpecSheet(match.product)}
|
||||||
|
aria-label={`下载${match.product.name}产品规格书`}
|
||||||
|
>
|
||||||
|
下载产品规格书 <span>↓</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -1857,7 +2270,7 @@ export default function Home() {
|
|||||||
aria-label={`查看${product.name}详情`}
|
aria-label={`查看${product.name}详情`}
|
||||||
>
|
>
|
||||||
{product.image ? (
|
{product.image ? (
|
||||||
<img src={product.image} alt={product.name} loading="lazy" />
|
<img src={getCatalogProductThumbnail(product.image)} alt={product.name} loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<span className="catalog-placeholder">GAUDELOT</span>
|
<span className="catalog-placeholder">GAUDELOT</span>
|
||||||
)}
|
)}
|
||||||
@@ -1882,6 +2295,14 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
加入到购物车 <span>+</span>
|
加入到购物车 <span>+</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="spec-sheet-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => requestSpecSheet(product)}
|
||||||
|
aria-label={`下载${product.name}产品规格书`}
|
||||||
|
>
|
||||||
|
下载产品规格书 <span>↓</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -1966,7 +2387,7 @@ export default function Home() {
|
|||||||
onClick={() => openCatalogProduct(product)}
|
onClick={() => openCatalogProduct(product)}
|
||||||
aria-label={`查看${product.name}大图`}
|
aria-label={`查看${product.name}大图`}
|
||||||
>
|
>
|
||||||
<img src={product.image} alt={product.name} loading="lazy" />
|
<img src={getCatalogProductThumbnail(product.image)} alt={product.name} loading="lazy" />
|
||||||
<span>点击查看大图</span>
|
<span>点击查看大图</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="precision-gear-card-body">
|
<div className="precision-gear-card-body">
|
||||||
@@ -1986,6 +2407,14 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
加入到购物车 <span>+</span>
|
加入到购物车 <span>+</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="spec-sheet-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => requestSpecSheet(product)}
|
||||||
|
aria-label={`下载${product.name}产品规格书`}
|
||||||
|
>
|
||||||
|
下载产品规格书 <span>↓</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -2099,6 +2528,14 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
加入到购物车 <span>+</span>
|
加入到购物车 <span>+</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="spec-sheet-button spec-sheet-modal-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => requestSpecSheet(selectedCatalogProduct)}
|
||||||
|
aria-label={`下载${selectedCatalogProduct.name}产品规格书`}
|
||||||
|
>
|
||||||
|
下载产品规格书 <span>↓</span>
|
||||||
|
</button>
|
||||||
<p>具体价格、交期及最终适配结果以工程师确认和正式文件为准。</p>
|
<p>具体价格、交期及最终适配结果以工程师确认和正式文件为准。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -2124,7 +2561,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div className="brand footer-brand">
|
<div className="brand footer-brand">
|
||||||
<span className="brand-wordmark"><strong>GAUDELOT</strong><small>高德乐</small></span>
|
<span className="brand-wordmark"><strong>GAUDELOT</strong><small>高徳乐</small></span>
|
||||||
</div>
|
</div>
|
||||||
<p>AI选型结果仅供初步筛选,最终型号、价格、交期与适配结果以工程师和正式文件确认为准。</p>
|
<p>AI选型结果仅供初步筛选,最终型号、价格、交期与适配结果以工程师和正式文件确认为准。</p>
|
||||||
<span>内部原型 · V1</span>
|
<span>内部原型 · V1</span>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
|
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
|
||||||
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
|
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
|
||||||
"build:static": "vite build --config vite.static.config.ts",
|
"build:static": "vite build --config vite.static.config.ts",
|
||||||
|
"images:optimize": "node scripts/optimize_catalog_images.mjs",
|
||||||
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
|
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
|
||||||
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
|
"lint": "eslint . --ignore-pattern dist --ignore-pattern dist-static --ignore-pattern .next",
|
||||||
"db:generate": "drizzle-kit generate"
|
"db:generate": "drizzle-kit generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
BIN
public/catalog-products/precision-gears-thumb/1000.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/260.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/329.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/330.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/331.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/332.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/catalog-products/precision-gears-thumb/333.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/334.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/335.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/336.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/337.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/338.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/339.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/340.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/341.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/342.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/343.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/344.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/345.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/346.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/347.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/catalog-products/precision-gears-thumb/348.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/catalog-products/precision-gears-thumb/349.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/350.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/351.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/352.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/353.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/354.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/355.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/356.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/358.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/359.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/catalog-products/precision-gears-thumb/360.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/361.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/362.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/363.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/364.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/365.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/366.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/catalog-products/precision-gears-thumb/367.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/368.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/369.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/370.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/371.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/372.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/373.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/374.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/375.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/376.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/377.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/378.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/379.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/catalog-products/precision-gears-thumb/380.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/381.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/catalog-products/precision-gears-thumb/382.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/383.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/catalog-products/precision-gears-thumb/384.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/catalog-products/precision-gears-thumb/385.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/386.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/387.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/388.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/389.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/catalog-products/precision-gears-thumb/390.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/391.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/392.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/393.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/394.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/395.webp
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/catalog-products/precision-gears-thumb/396.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/397.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/398.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/catalog-products/precision-gears-thumb/399.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/400.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/401.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/402.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/catalog-products/precision-gears-thumb/403.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/404.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/405.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/catalog-products/precision-gears-thumb/406.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/catalog-products/precision-gears-thumb/407.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/408.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/409.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/410.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/411.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/catalog-products/precision-gears-thumb/412.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/413.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/414.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/catalog-products/precision-gears-thumb/415.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/416.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/417.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/catalog-products/precision-gears-thumb/418.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/420.webp
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/catalog-products/precision-gears-thumb/421.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/catalog-products/precision-gears-thumb/426.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/catalog-products/precision-gears-thumb/427.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |