Improve catalog cards and cart exports

This commit is contained in:
Jacky Ser
2026-08-14 15:38:24 +08:00
parent 5101493901
commit 4d25114923
536 changed files with 555 additions and 100 deletions

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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",
}, },

View File

@@ -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.09920</b> kgf·cm</span> <span><b>0.09920</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>
<div className="recommendation-insights">
<div>
<strong></strong>
<ul className="reason-list"> <ul className="reason-list">
{match.reasons.slice(0, 2).map((reason) => <li key={reason}> {reason}</li>)} {match.reasons.length > 0
? match.reasons.slice(0, 2).map((reason) => <li key={reason}> {reason}</li>)
: <li> </li>}
</ul> </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>

View File

@@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Some files were not shown because too many files have changed in this diff Show More