Improve catalog cards and cart exports
@@ -118,6 +118,18 @@ export function getCatalogProductImage(product: CatalogProduct) {
|
||||
return product.image;
|
||||
}
|
||||
|
||||
export function getCatalogProductThumbnail(src: string) {
|
||||
if (src.includes("/catalog-products/precision-gears/")) {
|
||||
return src.replace("/catalog-products/precision-gears/", "/catalog-products/precision-gears-thumb/").replace(/\.(?:jpe?g|png)$/i, ".webp");
|
||||
}
|
||||
|
||||
if (src.includes("/catalog-products/source-main/")) {
|
||||
return src.replace("/catalog-products/source-main/", "/catalog-products/source-main-thumb/").replace(/\.(?:jpe?g|png)$/i, ".webp");
|
||||
}
|
||||
|
||||
return src;
|
||||
}
|
||||
|
||||
export function getCatalogProductDimensionImage(product: CatalogProduct) {
|
||||
if (product.category === "精密齿轮配件") {
|
||||
return product.dimensionImage ?? null;
|
||||
|
||||
@@ -187,6 +187,11 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
||||
.metric span { color: #8994a6; font-size: 8px; }
|
||||
.metric strong { font-size: 10px; }
|
||||
.reason-list { display: flex; gap: 10px; flex-wrap: wrap; margin: 12px 0 10px; padding: 0; list-style: none; color: #2d6e55; font-size: 9px; }
|
||||
.recommendation-insights { display: grid; gap: 7px; margin: 9px 0 10px; }
|
||||
.recommendation-insights > div { min-width: 0; padding: 7px 8px; border: 1px solid #e6edf6; border-radius: 8px; background: #fbfcfe; }
|
||||
.recommendation-insights > div > strong { display: block; color: #68778e; font-size: 8px; }
|
||||
.recommendation-insights .reason-list { margin: 5px 0 0; gap: 7px; font-size: 8px; }
|
||||
.recommendation-insights p { margin: 5px 0 0; color: #53637b; font-size: 8px; line-height: 1.45; }
|
||||
.price-line { display: flex; align-items: baseline; gap: 6px; padding-top: 9px; border-top: 1px solid #edf0f4; }
|
||||
.price-line span { color: #7c8798; font-size: 9px; }
|
||||
.price-line strong { color: #e55726; font-size: 20px; }
|
||||
@@ -361,8 +366,8 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
||||
.catalog-placeholder { color: #9eb3d3; font-size: 14px; font-weight: 800; letter-spacing: .12em; }
|
||||
.catalog-card-body { padding: 16px; }
|
||||
.catalog-card-body > p { margin: 0 0 5px; color: #8793a5; font-size: 9px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.catalog-card-body h3 { height: 42px; margin: 0 0 12px; overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||
.catalog-card-body dl { min-height: 0; margin: 0; padding: 10px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; border-radius: 9px; background: #f6f8fb; }
|
||||
.catalog-card-body h3 { min-height: 30px; max-height: 42px; height: auto; margin: 0 0 4px; overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||
.catalog-card-body dl { min-height: 0; margin: 0; padding: 10px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; border-radius: 9px; background: #f6f8fb; }
|
||||
.catalog-card-body dl div { min-width: 0; display: grid; align-content: start; gap: 4px; padding: 3px 0; font-size: 9px; }
|
||||
.catalog-card-body dt { overflow: hidden; color: #8a95a7; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.catalog-card-body dd { max-width: 100%; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-weight: 650; }
|
||||
@@ -385,6 +390,12 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
||||
.catalog-card-body > .add-cart-button { margin-top: 5px; }
|
||||
.precision-gear-card-body .add-cart-button { margin-top: 10px; }
|
||||
.recommendation-content .add-cart-button { margin-top: 10px; }
|
||||
.spec-sheet-button { width: 100%; min-height: 33px; margin-top: 5px; padding: 8px 10px; display: inline-flex; align-items: center; justify-content: space-between; border: 1px solid #f1c68f; border-radius: 8px; color: #a76219; background: #fffaf2; cursor: pointer; font-size: 10px; font-weight: 750; }
|
||||
.spec-sheet-button:hover { border-color: #e6a95d; color: #8d4e0e; background: #fff3df; }
|
||||
.spec-sheet-button span { font-size: 14px; line-height: 1; }
|
||||
.catalog-card-body > .spec-sheet-button { margin-top: 5px; }
|
||||
.precision-gear-card-body .spec-sheet-button { margin-top: 5px; }
|
||||
.recommendation-content .spec-sheet-button { margin-top: 5px; }
|
||||
.cart-toast { position: fixed; left: 50%; bottom: 24px; z-index: 120; transform: translateX(-50%); max-width: min(90vw, 380px); padding: 10px 15px; border: 1px solid #cbdaf2; border-radius: 999px; color: #174baf; background: rgba(248,251,255,.96); box-shadow: 0 12px 32px rgba(24,64,126,.18); font-size: 11px; font-weight: 750; text-align: center; backdrop-filter: blur(10px); }
|
||||
.cart-drawer-backdrop { position: fixed; inset: 0; z-index: 110; display: flex; justify-content: flex-end; background: rgba(7,14,29,.45); backdrop-filter: blur(4px); }
|
||||
.cart-drawer { width: min(430px, 100%); height: 100%; padding: 28px 24px 22px; display: flex; flex-direction: column; overflow: hidden; background: white; box-shadow: -18px 0 50px rgba(20,43,82,.2); }
|
||||
@@ -601,6 +612,7 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
||||
.catalog-modal-actions { display: flex; align-items: center; gap: 18px; margin-top: 20px; }
|
||||
.catalog-modal-actions .panel-primary { flex: 0 0 auto; }
|
||||
.catalog-modal-actions .modal-cart-button { width: auto; min-width: 124px; margin-top: 0; flex: 0 0 auto; }
|
||||
.catalog-modal-actions .spec-sheet-button { width: auto; min-width: 124px; margin-top: 0; flex: 0 0 auto; }
|
||||
.catalog-modal-actions p { margin: 0; color: #8b95a5; font-size: 9px; line-height: 1.6; }
|
||||
.how-section { padding: 100px max(32px, calc((100vw - 1180px) / 2)); display: grid; grid-template-columns: .9fr 1.1fr; gap: 100px; color: white; background: linear-gradient(135deg, #0c1528, #14284e); }
|
||||
.eyebrow.light { color: #77a5ff; }
|
||||
@@ -704,8 +716,8 @@ footer > span { color: #9aa4b2; font-size: 10px; }
|
||||
.precision-gear-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
|
||||
.precision-gear-card-body { padding: 9px; }
|
||||
.catalog-card-body { padding: 15px; }
|
||||
.catalog-card-body h3 { height: auto; min-height: 42px; font-size: 14px; }
|
||||
.catalog-card-body dl { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 9px 8px; }
|
||||
.catalog-card-body h3 { height: auto; min-height: 30px; max-height: 42px; margin-bottom: 4px; font-size: 14px; }
|
||||
.catalog-card-body dl { min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; padding: 9px 7px; }
|
||||
.catalog-card-body dl div { min-width: 0; display: grid; align-content: start; justify-content: initial; gap: 3px; padding: 2px 0; font-size: 8px; }
|
||||
.catalog-card-body dt { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.catalog-card-body dd { max-width: 100%; overflow: hidden; font-size: 9px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -730,6 +742,8 @@ footer > span { color: #9aa4b2; font-size: 10px; }
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.catalog-spec-table { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cart-drawer-footer > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cart-sample-form-actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -11,10 +11,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
metadataBase,
|
||||
title: "高徳乐 AI 舵机选型助手",
|
||||
description: "高徳乐280个产品型号完整目录,支持搜索筛选、AI舵机初选与样品申请。",
|
||||
description: "高徳乐243款舵机+配件,共286个产品型号,支持搜索筛选、AI舵机初选与样品申请。",
|
||||
openGraph: {
|
||||
title: "高徳乐 AI 舵机选型助手",
|
||||
description: "280个型号完整目录。3分钟找到适合你的舵机,AI初选,工程师复核。",
|
||||
description: "243款舵机+配件,共286个型号。3分钟找到适合你的舵机,AI初选,工程师复核。",
|
||||
images: [{ url: new URL("/og.png", metadataBase).toString(), width: 1200, height: 630 }],
|
||||
type: "website",
|
||||
},
|
||||
|
||||
527
app/page.tsx
@@ -8,7 +8,7 @@ import {
|
||||
} from "./catalog-data";
|
||||
import { STEERING_WHEEL_ACCESSORIES } from "./catalog-accessories";
|
||||
import { PRECISION_GEAR_PRODUCTS, PRECISION_GEAR_SUBCATEGORIES } from "./precision-gear-data";
|
||||
import { getCatalogProductDimensionImage, getCatalogProductImage } from "./catalog-image-map";
|
||||
import { getCatalogProductDimensionImage, getCatalogProductImage, getCatalogProductThumbnail } from "./catalog-image-map";
|
||||
|
||||
const CATALOG_PRODUCTS = [...RAW_CATALOG_PRODUCTS, ...STEERING_WHEEL_ACCESSORIES].map((product) => ({
|
||||
...product,
|
||||
@@ -59,6 +59,7 @@ const FEATURED_SPEC_ORDER = [
|
||||
"堵转扭矩",
|
||||
"额定电压",
|
||||
"电压范围",
|
||||
"可操作角度",
|
||||
"信号类型",
|
||||
"通讯方式",
|
||||
"齿轮比",
|
||||
@@ -74,7 +75,7 @@ const FEATURED_SPEC_ORDER = [
|
||||
function getFeaturedSpecs(product: CatalogProduct) {
|
||||
return FEATURED_SPEC_ORDER
|
||||
.filter((label) => product.specs[label])
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
.map((label) => [label, product.specs[label]] as const);
|
||||
}
|
||||
|
||||
@@ -232,12 +233,16 @@ const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) =>
|
||||
).map((product) => {
|
||||
const voltageValues = parseNumbers(product.specs["电压范围"]);
|
||||
const ratedVoltage = product.ratedVoltage ?? 0;
|
||||
const control = [
|
||||
const controlSearch = [
|
||||
product.specs["信号类型"],
|
||||
product.specs["通讯方式"],
|
||||
product.specs["端子连接线"],
|
||||
product.specs["连接线类型"],
|
||||
].filter(Boolean).join(" · ");
|
||||
const controlProtocol = [product.specs["信号类型"], product.specs["通讯方式"]]
|
||||
.filter(Boolean).join(" · ") || "目录未标注";
|
||||
const terminalConnection = [product.specs["端子连接线"], product.specs["连接线类型"]]
|
||||
.filter(Boolean).join(" · ") || "目录未标注";
|
||||
return {
|
||||
...product,
|
||||
torque: product.torqueValue ?? 0,
|
||||
@@ -247,7 +252,9 @@ const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) =>
|
||||
isSilent: isSilentServo(product),
|
||||
gear: classifyGear(product.specs["齿轮材质"]),
|
||||
angle: product.specs["可操作角度"] ?? product.specs["操作角度范围"] ?? "",
|
||||
control,
|
||||
controlSearch,
|
||||
controlProtocol,
|
||||
terminalConnection,
|
||||
speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "",
|
||||
size: product.specs["产品尺寸"] ?? product.specs["外形尺寸"] ?? "",
|
||||
};
|
||||
@@ -288,7 +295,7 @@ function getMatches(requirements: Requirements) {
|
||||
requirements.weightLimit === 0 || product.weight === null || product.weight <= requirements.weightLimit;
|
||||
const gearOk = requirements.gear === "不限" || requirements.gear === product.gear;
|
||||
const angleOk = angleMatches(requirements.angle, product.angle);
|
||||
const controlOk = controlMatches(requirements.control, product.control);
|
||||
const controlOk = controlMatches(requirements.control, product.controlSearch);
|
||||
const coreCompatible = categoryOk && torqueOk && voltageOk && weightOk && gearOk && angleOk && controlOk;
|
||||
|
||||
if (categoryOk) score += requirements.category === "全部舵机" ? 6 : 16;
|
||||
@@ -341,6 +348,37 @@ function getMatches(requirements: Requirements) {
|
||||
}).sort((a, b) => Number(b.coreCompatible) - Number(a.coreCompatible) || b.score - a.score);
|
||||
}
|
||||
|
||||
type SelectorMatch = ReturnType<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 }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
@@ -365,6 +403,9 @@ type CartExportImage = {
|
||||
|
||||
const CART_EXPORT_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
const CART_IMAGE_SIZE_PX = 189; // 约 5 cm,按 96 DPI 换算
|
||||
const PDF_MIME = "application/pdf";
|
||||
const PDF_PAGE_WIDTH = 595;
|
||||
const PDF_PAGE_HEIGHT = 842;
|
||||
|
||||
function getCartExportFilename() {
|
||||
const now = new Date();
|
||||
@@ -373,6 +414,10 @@ function getCartExportFilename() {
|
||||
return `高徳乐产品清单_${date}.xlsx`;
|
||||
}
|
||||
|
||||
function getCartPdfFilename() {
|
||||
return getCartExportFilename().replace(/\.xlsx$/, ".pdf");
|
||||
}
|
||||
|
||||
function getFullSpecEntries(product: CatalogProduct) {
|
||||
return Object.entries(product.specs).filter(([, value]) => value);
|
||||
}
|
||||
@@ -442,7 +487,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) {
|
||||
summarySheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
detailsSheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
summarySheet.columns = [
|
||||
{ header: "产品图片", key: "image", width: 27 },
|
||||
{ header: "产品图片", key: "image", width: 36 },
|
||||
{ header: "产品名称", key: "name", width: 34 },
|
||||
{ header: "产品编码", key: "code", width: 24 },
|
||||
{ header: "产品分类", key: "category", width: 18 },
|
||||
@@ -507,7 +552,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) {
|
||||
if (imageAsset) {
|
||||
const imageId = workbook.addImage(imageAsset);
|
||||
summarySheet.addImage(imageId, {
|
||||
tl: { col: 0.1, row: summaryRow.number - 1 + 0.08 },
|
||||
tl: { col: 0.25, row: summaryRow.number - 1 + 0.08 },
|
||||
ext: { width: CART_IMAGE_SIZE_PX, height: CART_IMAGE_SIZE_PX },
|
||||
});
|
||||
summaryRow.getCell(1).value = null;
|
||||
@@ -543,7 +588,7 @@ async function buildCartWorkbook(cartItems: CartItem[]) {
|
||||
return workbook;
|
||||
}
|
||||
|
||||
function downloadCartWorkbook(blob: Blob, filename: string) {
|
||||
function downloadFile(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -559,6 +604,330 @@ function isMobileDevice() {
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
type NativePdfImage = {
|
||||
bytes: Uint8Array;
|
||||
height: number;
|
||||
name: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
type NativePdfPage = {
|
||||
images: Set<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 = {
|
||||
product: CatalogProduct;
|
||||
@@ -782,6 +1151,7 @@ export default function Home() {
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [cartMessage, setCartMessage] = useState("");
|
||||
const [exportingCart, setExportingCart] = useState(false);
|
||||
const [exportingPdf, setExportingPdf] = useState(false);
|
||||
const [cartSampleFormOpen, setCartSampleFormOpen] = useState(false);
|
||||
const [cartSampleSubmitted, setCartSampleSubmitted] = useState(false);
|
||||
const [cartSampleLeadId, setCartSampleLeadId] = useState("");
|
||||
@@ -896,6 +1266,11 @@ export default function Home() {
|
||||
window.setTimeout(() => setCartMessage(""), 2200);
|
||||
}
|
||||
|
||||
function requestSpecSheet(product: CatalogProduct) {
|
||||
setCartMessage(`${product.name} 的产品规格书资料待补充`);
|
||||
window.setTimeout(() => setCartMessage(""), 2400);
|
||||
}
|
||||
|
||||
function addTopMatchesToCart() {
|
||||
if (topMatches.length === 0) return;
|
||||
setCartItems((current) => {
|
||||
@@ -975,7 +1350,7 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
downloadCartWorkbook(file, filename);
|
||||
downloadFile(file, filename);
|
||||
setCartMessage("产品清单已下载");
|
||||
} catch (error) {
|
||||
console.error("生成购物车清单失败", error);
|
||||
@@ -986,24 +1361,42 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
function exportCartPdf() {
|
||||
if (cartItems.length === 0) return;
|
||||
const previousTitle = document.title;
|
||||
document.body.classList.add("printing-cart");
|
||||
document.title = getCartExportFilename().replace(/\.xlsx$/, ".pdf");
|
||||
setCartOpen(false);
|
||||
let fallbackCleanupTimer = 0;
|
||||
const cleanupPrintMode = () => {
|
||||
window.clearTimeout(fallbackCleanupTimer);
|
||||
window.removeEventListener("afterprint", cleanupPrintMode);
|
||||
document.body.classList.remove("printing-cart");
|
||||
document.title = previousTitle;
|
||||
};
|
||||
window.addEventListener("afterprint", cleanupPrintMode);
|
||||
fallbackCleanupTimer = window.setTimeout(cleanupPrintMode, 30000);
|
||||
window.setTimeout(() => {
|
||||
window.print();
|
||||
}, 80);
|
||||
async function exportCartPdf() {
|
||||
if (cartItems.length === 0 || exportingPdf) return;
|
||||
|
||||
setExportingPdf(true);
|
||||
setCartMessage("正在生成 PDF…");
|
||||
try {
|
||||
const blob = await buildCartPdf(cartItems);
|
||||
const filename = getCartPdfFilename();
|
||||
const file = new File([blob], filename, { type: PDF_MIME });
|
||||
|
||||
if (isMobileDevice() && typeof navigator.share === "function") {
|
||||
const canShareFiles = typeof navigator.canShare !== "function" || navigator.canShare({ files: [file] });
|
||||
if (canShareFiles) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: "高徳乐产品清单",
|
||||
text: "购物车产品清单 PDF",
|
||||
files: [file],
|
||||
});
|
||||
setCartMessage("已打开系统分享面板");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downloadFile(file, filename);
|
||||
setCartMessage("PDF 文件已下载");
|
||||
} catch (error) {
|
||||
console.error("生成购物车 PDF 失败", error);
|
||||
setCartMessage("PDF 生成失败,请稍后重试");
|
||||
} finally {
|
||||
setExportingPdf(false);
|
||||
window.setTimeout(() => setCartMessage(""), 2400);
|
||||
}
|
||||
}
|
||||
|
||||
function startSelection() {
|
||||
@@ -1104,7 +1497,7 @@ export default function Home() {
|
||||
{cartItems.map((item) => (
|
||||
<div className="cart-item" key={item.product.id}>
|
||||
<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 className="cart-item-copy">
|
||||
<small>{item.source}</small>
|
||||
@@ -1139,10 +1532,10 @@ export default function Home() {
|
||||
className="outline-button cart-pdf-button"
|
||||
type="button"
|
||||
onClick={exportCartPdf}
|
||||
disabled={cartItems.length === 0}
|
||||
aria-label="导出购物车 PDF"
|
||||
disabled={cartItems.length === 0 || exportingPdf}
|
||||
aria-label={exportingPdf ? "正在生成购物车 PDF" : "导出购物车 PDF"}
|
||||
>
|
||||
导出 PDF
|
||||
{exportingPdf ? "生成中…" : "导出 PDF"}
|
||||
</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>
|
||||
@@ -1173,7 +1566,7 @@ export default function Home() {
|
||||
<h1>产品清单</h1>
|
||||
<span>购物车共 {cartItems.length} 个型号 / {cartCount} 件产品</span>
|
||||
</div>
|
||||
<strong>{getCartExportFilename().replace(/\.xlsx$/, ".pdf")}</strong>
|
||||
<strong>{getCartPdfFilename()}</strong>
|
||||
</div>
|
||||
<table className="cart-print-summary">
|
||||
<thead>
|
||||
@@ -1227,10 +1620,10 @@ export default function Home() {
|
||||
<p className="cart-print-footer">高徳乐 AI 舵机选型助手 · 本清单用于客户沟通、选型确认和打印留档,最终参数以正式技术文件为准。</p>
|
||||
</section>
|
||||
<header className="site-header">
|
||||
<a className="brand" href="#top" aria-label="高德乐 AI选型首页">
|
||||
<a className="brand" href="#top" aria-label="高徳乐 AI选型首页">
|
||||
<span className="brand-wordmark">
|
||||
<strong>GAUDELOT</strong>
|
||||
<small>高德乐</small>
|
||||
<small>高徳乐</small>
|
||||
</span>
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
<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>48h</b> 方案响应</span>
|
||||
</div>
|
||||
@@ -1449,7 +1842,7 @@ export default function Home() {
|
||||
</select>
|
||||
</label>
|
||||
<label className="select-field">
|
||||
<span>控制 / 通讯方式</span>
|
||||
<span>控制协议</span>
|
||||
<select
|
||||
value={requirements.control}
|
||||
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={`${requirements.voltage.toFixed(1)}V`} />
|
||||
<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.annualVolume} />
|
||||
</div>
|
||||
@@ -1653,7 +2046,7 @@ export default function Home() {
|
||||
onClick={() => openCatalogProduct(match.product)}
|
||||
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">
|
||||
{index === 0 ? (match.coreCompatible ? "首选推荐" : "待复核候选") : `${match.confidence}% 匹配`}
|
||||
</span>
|
||||
@@ -1668,12 +2061,24 @@ export default function Home() {
|
||||
<Metric label="电压范围" value={`${match.product.voltageMin}–${match.product.voltageMax}V`} />
|
||||
<Metric label="空载速度" value={match.product.speed || "目录未标注"} />
|
||||
<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} />
|
||||
</div>
|
||||
<ul className="reason-list">
|
||||
{match.reasons.slice(0, 2).map((reason) => <li key={reason}>✓ {reason}</li>)}
|
||||
</ul>
|
||||
<div className="recommendation-insights">
|
||||
<div>
|
||||
<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">
|
||||
<span>官方目录型号</span>
|
||||
<strong>{match.product.category}</strong>
|
||||
@@ -1687,6 +2092,14 @@ export default function Home() {
|
||||
>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
<button
|
||||
className="spec-sheet-button"
|
||||
type="button"
|
||||
onClick={() => requestSpecSheet(match.product)}
|
||||
aria-label={`下载${match.product.name}产品规格书`}
|
||||
>
|
||||
下载产品规格书 <span>↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -1857,7 +2270,7 @@ export default function Home() {
|
||||
aria-label={`查看${product.name}详情`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
@@ -1882,6 +2295,14 @@ export default function Home() {
|
||||
>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
<button
|
||||
className="spec-sheet-button"
|
||||
type="button"
|
||||
onClick={() => requestSpecSheet(product)}
|
||||
aria-label={`下载${product.name}产品规格书`}
|
||||
>
|
||||
下载产品规格书 <span>↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -1966,7 +2387,7 @@ export default function Home() {
|
||||
onClick={() => openCatalogProduct(product)}
|
||||
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>
|
||||
</button>
|
||||
<div className="precision-gear-card-body">
|
||||
@@ -1986,6 +2407,14 @@ export default function Home() {
|
||||
>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
<button
|
||||
className="spec-sheet-button"
|
||||
type="button"
|
||||
onClick={() => requestSpecSheet(product)}
|
||||
aria-label={`下载${product.name}产品规格书`}
|
||||
>
|
||||
下载产品规格书 <span>↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -2099,6 +2528,14 @@ export default function Home() {
|
||||
>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
<button
|
||||
className="spec-sheet-button spec-sheet-modal-button"
|
||||
type="button"
|
||||
onClick={() => requestSpecSheet(selectedCatalogProduct)}
|
||||
aria-label={`下载${selectedCatalogProduct.name}产品规格书`}
|
||||
>
|
||||
下载产品规格书 <span>↓</span>
|
||||
</button>
|
||||
<p>具体价格、交期及最终适配结果以工程师确认和正式文件为准。</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -2124,7 +2561,7 @@ export default function Home() {
|
||||
|
||||
<footer>
|
||||
<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>
|
||||
<p>AI选型结果仅供初步筛选,最终型号、价格、交期与适配结果以工程师和正式文件确认为准。</p>
|
||||
<span>内部原型 · V1</span>
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
|
||||
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"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 |