Files
gdm-Servo-model/app/page.tsx
2026-08-14 15:38:24 +08:00

2572 lines
112 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
CATALOG_COUNTS,
CATALOG_PRODUCTS as RAW_CATALOG_PRODUCTS,
type CatalogProduct,
} from "./catalog-data";
import { STEERING_WHEEL_ACCESSORIES } from "./catalog-accessories";
import { PRECISION_GEAR_PRODUCTS, PRECISION_GEAR_SUBCATEGORIES } from "./precision-gear-data";
import { getCatalogProductDimensionImage, getCatalogProductImage, getCatalogProductThumbnail } from "./catalog-image-map";
const CATALOG_PRODUCTS = [...RAW_CATALOG_PRODUCTS, ...STEERING_WHEEL_ACCESSORIES].map((product) => ({
...product,
image: getCatalogProductImage(product),
}));
type Requirements = {
application: string;
category: string;
torque: number;
voltage: number;
weightLimit: number;
angle: string;
control: string;
gear: string;
annualVolume: string;
firstOrderQuantity: string;
currentModel: string;
otherRequirements: string;
painPoint: string;
};
type CatalogVisualMode = "main" | "size";
const APPLICATIONS = [
{ name: "AI毛绒", note: "头部、耳朵、手臂等轻量动作" },
{ name: "智能玩具", note: "互动机构、表情和行走结构" },
{ name: "教育/积木", note: "STEAM套件与积木动力模块" },
{ name: "微型机器人", note: "关节、夹爪和小型机械臂" },
{ name: "云台/航模", note: "飞行控制、云台与模型机构" },
{ name: "工业设备", note: "控制机构与定制传动组件" },
];
const PRIMARY_CATALOG_CATEGORIES = [
"标准舵机",
"异形舵机",
"全金属舵机",
"大扭矩舵机",
"积木舵机",
"标准齿轮箱",
"翻盖电机",
"舵盘配件",
];
const CATALOG_FILTER_CATEGORIES = ["全部产品", ...PRIMARY_CATALOG_CATEGORIES, "静音舵机"];
const FEATURED_SPEC_ORDER = [
"堵转扭矩",
"额定电压",
"电压范围",
"可操作角度",
"信号类型",
"通讯方式",
"齿轮比",
"寿命",
"花键大小",
"类别",
"材质",
"重量",
"产品尺寸",
"外形尺寸",
];
function getFeaturedSpecs(product: CatalogProduct) {
return FEATURED_SPEC_ORDER
.filter((label) => product.specs[label])
.slice(0, 4)
.map((label) => [label, product.specs[label]] as const);
}
const PRECISION_GEAR_CARD_SPEC_ORDER = [
"模数",
"齿数",
"材质",
"外径mm",
"长mm",
"宽mm",
"高mm",
"孔径",
"类别",
];
function getPrecisionGearCardSpecs(product: CatalogProduct) {
return PRECISION_GEAR_CARD_SPEC_ORDER
.filter((label) => product.specs[label])
.slice(0, 2)
.map((label) => [label, product.specs[label]] as const);
}
const SERVO_CATEGORIES = ["标准舵机", "异形舵机", "全金属舵机", "大扭矩舵机", "积木舵机"];
const SILENT_SERVO_CATEGORY = "静音舵机";
const CUSTOM_SERVO_CATEGORY = "非标准舵机定制";
const TORQUE_MIN = 0.09;
const TORQUE_MAX = 920;
const TORQUE_PRESETS = [0.1, 1, 5, 20, 100, 300, 920];
const APPLICATION_CATEGORY_MAP: Record<string, string[]> = {
AI毛绒: ["标准舵机", "异形舵机"],
: ["标准舵机", "异形舵机", "积木舵机"],
"教育/积木": ["积木舵机", "标准舵机"],
: ["标准舵机", "异形舵机", "全金属舵机"],
"云台/航模": ["标准舵机", "全金属舵机"],
: ["全金属舵机", "大扭矩舵机", "异形舵机"],
};
function parseNumbers(value = "") {
return (value.match(/\d+(?:\.\d+)?/g) ?? []).map(Number);
}
function isSilentServo(product: CatalogProduct) {
return SERVO_CATEGORIES.includes(product.category) &&
[product.name, ...Object.values(product.specs)].join(" ").includes("静音");
}
const SILENT_SERVO_COUNT = CATALOG_PRODUCTS.filter(isSilentServo).length;
function getCategoryCount(category: string) {
return category === SILENT_SERVO_CATEGORY
? SILENT_SERVO_COUNT
: category === "舵盘配件"
? STEERING_WHEEL_ACCESSORIES.length
: CATALOG_COUNTS[category];
}
type ProductScale = {
key: string;
label: string;
unit: "g" | "kg";
value: number;
};
function getProductScale(product: CatalogProduct): ProductScale | null {
const kilogramMatch = product.name.match(/(\d+(?:\.\d+)?)\s*(?:kg|千克)/i);
if (kilogramMatch) {
const value = Number(kilogramMatch[1]);
return { key: `kg:${value}`, label: `${value}kg`, unit: "kg", value };
}
const gramMatch = product.name.match(/(\d+(?:\.\d+)?)\s*(?:g|克)/i);
if (gramMatch) {
const value = Number(gramMatch[1]);
return { key: `g:${value}`, label: `${value}g`, unit: "g", value };
}
return null;
}
const PRODUCT_SCALE_OPTIONS = [...CATALOG_PRODUCTS.reduce((options, product) => {
const scale = getProductScale(product);
if (!scale) return options;
const current = options.get(scale.key);
options.set(scale.key, { ...scale, count: (current?.count ?? 0) + 1 });
return options;
}, new Map<string, ProductScale & { count: number }>()).values()].sort(
(a, b) => (a.unit === b.unit ? a.value - b.value : a.unit === "g" ? -1 : 1),
);
const GRAM_SCALE_OPTIONS = PRODUCT_SCALE_OPTIONS.filter((option) => option.unit === "g");
const KILOGRAM_SCALE_OPTIONS = PRODUCT_SCALE_OPTIONS.filter((option) => option.unit === "kg");
const UNMARKED_SCALE_COUNT = CATALOG_PRODUCTS.length - PRODUCT_SCALE_OPTIONS.reduce(
(total, option) => total + option.count,
0,
);
function inferWeight(product: CatalogProduct) {
const listedWeight = parseNumbers(product.specs["重量"])[0];
if (Number.isFinite(listedWeight)) return listedWeight;
if (product.category !== "标准舵机") return null;
const namedWeight = product.name.match(/(\d+(?:\.\d+)?)\s*(?:g|克)/i);
return namedWeight ? Number(namedWeight[1]) : null;
}
function classifyGear(value = "") {
const hasPlastic = /塑胶|塑料/.test(value);
const hasMetal = /金属|铜|粉末冶金|滚齿/.test(value);
if (hasPlastic && hasMetal) return "混合齿";
if (hasMetal) return "全金属齿";
if (hasPlastic) return "塑胶齿";
return "未标注";
}
function controlMatches(control: string, productControl: string) {
if (control === "不限") return true;
const value = productControl.toUpperCase();
if (control === "PWM/三线") return value.includes("PWM") || /3线|3PIN/.test(value);
if (control === "串口/TTL") return value.includes("TTL") || value.includes("串口");
if (control === "二/四/五线") return /[245]线|[245]PIN/.test(value);
return value.includes(control.toUpperCase());
}
function angleMatches(angle: string, productAngle: string) {
if (angle === "不限") return true;
if (!productAngle) return false;
const firstAngle = parseNumbers(productAngle)[0] ?? 0;
if (angle === "135°") return firstAngle > 0 && firstAngle <= 135;
if (angle === "180°") return firstAngle > 135 && firstAngle <= 180;
if (angle === "270330°") return firstAngle >= 270 && firstAngle < 360;
return productAngle.includes("多圈") || firstAngle >= 360;
}
function formatTorque(value: number) {
if (value < 1) return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
if (value < 100) return value.toFixed(1).replace(/\.0$/, "");
return Math.round(value).toString();
}
function torqueToSlider(value: number) {
return ((Math.log(value) - Math.log(TORQUE_MIN)) / (Math.log(TORQUE_MAX) - Math.log(TORQUE_MIN))) * 100;
}
function sliderToTorque(position: number) {
const value = Math.exp(
Math.log(TORQUE_MIN) + (position / 100) * (Math.log(TORQUE_MAX) - Math.log(TORQUE_MIN)),
);
if (value < 1) return Number(value.toFixed(2));
if (value < 100) return Number(value.toFixed(1));
return Math.round(value);
}
const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) =>
SERVO_CATEGORIES.includes(product.category),
).map((product) => {
const voltageValues = parseNumbers(product.specs["电压范围"]);
const ratedVoltage = product.ratedVoltage ?? 0;
const controlSearch = [
product.specs["信号类型"],
product.specs["通讯方式"],
product.specs["端子连接线"],
product.specs["连接线类型"],
].filter(Boolean).join(" · ");
const controlProtocol = [product.specs["信号类型"], product.specs["通讯方式"]]
.filter(Boolean).join(" · ") || "目录未标注";
const terminalConnection = [product.specs["端子连接线"], product.specs["连接线类型"]]
.filter(Boolean).join(" · ") || "目录未标注";
return {
...product,
torque: product.torqueValue ?? 0,
voltageMin: voltageValues[0] ?? ratedVoltage,
voltageMax: voltageValues.at(-1) ?? ratedVoltage,
weight: inferWeight(product),
isSilent: isSilentServo(product),
gear: classifyGear(product.specs["齿轮材质"]),
angle: product.specs["可操作角度"] ?? product.specs["操作角度范围"] ?? "",
controlSearch,
controlProtocol,
terminalConnection,
speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "",
size: product.specs["产品尺寸"] ?? product.specs["外形尺寸"] ?? "",
};
});
const RATED_VOLTAGES = [...new Set(
SELECTOR_PRODUCTS
.map((product) => product.ratedVoltage)
.filter((voltage): voltage is number => voltage !== null),
)].sort((a, b) => a - b);
const DEFAULT_REQUIREMENTS: Requirements = {
application: "",
category: "全部舵机",
torque: 2,
voltage: 5,
weightLimit: 0,
angle: "不限",
control: "不限",
gear: "不限",
annualVolume: "500050000件",
firstOrderQuantity: "",
currentModel: "",
otherRequirements: "",
painPoint: "交期",
};
function getMatches(requirements: Requirements) {
return SELECTOR_PRODUCTS.map((product) => {
let score = 20;
const reasons: string[] = [];
const categoryOk = requirements.category === "全部舵机" ||
requirements.category === product.category ||
(requirements.category === SILENT_SERVO_CATEGORY && product.isSilent);
const torqueOk = product.torque >= requirements.torque;
const voltageOk = requirements.voltage === product.ratedVoltage;
const weightOk =
requirements.weightLimit === 0 || product.weight === null || product.weight <= requirements.weightLimit;
const gearOk = requirements.gear === "不限" || requirements.gear === product.gear;
const angleOk = angleMatches(requirements.angle, product.angle);
const controlOk = controlMatches(requirements.control, product.controlSearch);
const coreCompatible = categoryOk && torqueOk && voltageOk && weightOk && gearOk && angleOk && controlOk;
if (categoryOk) score += requirements.category === "全部舵机" ? 6 : 16;
else score -= 120;
if (APPLICATION_CATEGORY_MAP[requirements.application]?.includes(product.category)) {
score += 12;
reasons.push(`适合${requirements.application}场景`);
}
if (torqueOk) {
const ratio = product.torque / requirements.torque;
score += Math.max(5, 28 - Math.log10(Math.max(1, ratio)) * 9);
const margin = Math.round((product.torque / requirements.torque - 1) * 100);
reasons.push(`扭矩满足,约有${Math.max(0, margin)}%余量`);
} else {
score -= 120;
}
if (voltageOk) {
score += 22;
reasons.push(`${requirements.voltage}V额定电压匹配`);
} else {
score -= 90;
}
if (requirements.weightLimit > 0) {
if (product.weight === null) score -= 6;
else if (weightOk) {
score += 10;
reasons.push(`重量${product.weight}g符合上限`);
} else score -= 45;
}
if (requirements.gear !== "不限") score += gearOk ? 10 : -40;
if (requirements.angle !== "不限") score += angleOk ? 10 : -40;
if (requirements.control !== "不限") score += controlOk ? 12 : -50;
if (requirements.painPoint === "成本" && product.gear === "塑料齿") score += 8;
if (requirements.painPoint === "耐久" && product.gear === "全金属齿") score += 8;
if (requirements.painPoint === "体积" && product.weight !== null && product.weight <= 9) score += 8;
return {
product,
score,
coreCompatible,
confidence: Math.max(48, Math.min(97, Math.round(score))),
reasons: reasons.slice(0, 3),
};
}).sort((a, b) => Number(b.coreCompatible) - Number(a.coreCompatible) || b.score - a.score);
}
type SelectorMatch = ReturnType<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">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
type CartItem = {
product: CatalogProduct;
quantity: number;
source: string;
};
const CART_STORAGE_KEY = "gaudelo-product-cart";
type CartExportImage = {
base64: string;
extension: "jpeg";
};
const CART_EXPORT_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
const CART_IMAGE_SIZE_PX = 189; // 约 5 cm按 96 DPI 换算
const PDF_MIME = "application/pdf";
const PDF_PAGE_WIDTH = 595;
const PDF_PAGE_HEIGHT = 842;
function getCartExportFilename() {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, "0");
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
return `高徳乐产品清单_${date}.xlsx`;
}
function getCartPdfFilename() {
return getCartExportFilename().replace(/\.xlsx$/, ".pdf");
}
function getFullSpecEntries(product: CatalogProduct) {
return Object.entries(product.specs).filter(([, value]) => value);
}
function getKeySpecText(product: CatalogProduct) {
const operationAngle = product.specs["可操作角度"] ?? product.specs["操作角度范围"];
return [
...getFeaturedSpecs(product),
...(operationAngle ? [["可操作角度", operationAngle] as const] : []),
]
.map(([label, value]) => `${label}${value}`)
.join("\n") || "暂无主要参数";
}
function getFullSpecText(product: CatalogProduct) {
return getFullSpecEntries(product)
.map(([label, value]) => `${label}${value}`)
.join("\n") || "暂无详细参数";
}
function compressCartImage(src: string) {
return new Promise<CartExportImage | null>((resolve) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = CART_IMAGE_SIZE_PX;
canvas.height = CART_IMAGE_SIZE_PX;
const context = canvas.getContext("2d");
if (!context) {
resolve(null);
return;
}
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
const padding = 10;
const scale = Math.min(
(canvas.width - padding * 2) / image.naturalWidth,
(canvas.height - padding * 2) / image.naturalHeight,
);
const imageWidth = image.naturalWidth * scale;
const imageHeight = image.naturalHeight * scale;
context.drawImage(
image,
(canvas.width - imageWidth) / 2,
(canvas.height - imageHeight) / 2,
imageWidth,
imageHeight,
);
resolve({
base64: canvas.toDataURL("image/jpeg", 0.78),
extension: "jpeg",
});
};
image.onerror = () => resolve(null);
image.src = src;
});
}
async function buildCartWorkbook(cartItems: CartItem[]) {
const { default: ExcelJS } = await import("exceljs");
const workbook = new ExcelJS.Workbook();
workbook.creator = "高徳乐 AI 舵机选型助手";
workbook.created = new Date();
const summarySheet = workbook.addWorksheet("产品清单");
const detailsSheet = workbook.addWorksheet("详细参数");
summarySheet.views = [{ state: "frozen", ySplit: 1 }];
detailsSheet.views = [{ state: "frozen", ySplit: 1 }];
summarySheet.columns = [
{ header: "产品图片", key: "image", width: 36 },
{ header: "产品名称", key: "name", width: 34 },
{ header: "产品编码", key: "code", width: 24 },
{ header: "产品分类", key: "category", width: 18 },
{ header: "购买数量", key: "quantity", width: 12 },
{ header: "详细参数", key: "detailSpecs", width: 48 },
];
detailsSheet.columns = [
{ header: "产品名称", key: "name", width: 34 },
{ header: "产品编码", key: "code", width: 24 },
{ header: "产品分类", key: "category", width: 18 },
{ header: "购买数量", key: "quantity", width: 12 },
{ header: "参数名称", key: "label", width: 22 },
{ header: "参数值", key: "value", width: 48 },
];
const headerStyle = {
font: { bold: true, color: { argb: "FFFFFFFF" } },
fill: { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FF185EE8" } },
alignment: { vertical: "middle" as const, horizontal: "center" as const, wrapText: true },
};
summarySheet.getRow(1).eachCell((cell) => {
Object.assign(cell, headerStyle);
});
detailsSheet.getRow(1).eachCell((cell) => {
Object.assign(cell, headerStyle);
});
summarySheet.getRow(1).height = 26;
detailsSheet.getRow(1).height = 26;
const imageCache = new Map<string, Promise<CartExportImage | null>>();
const getImageAsset = (src: string) => {
const cached = imageCache.get(src);
if (cached) return cached;
const pending = compressCartImage(src);
imageCache.set(src, pending);
return pending;
};
for (const item of cartItems) {
const product = item.product;
const specEntries = getFullSpecEntries(product);
const summaryRow = summarySheet.addRow({
image: product.image ? "" : "暂无图片",
name: product.name,
code: product.code,
category: product.category,
quantity: item.quantity,
detailSpecs: getFullSpecText(product),
});
summaryRow.height = Math.max(142, Math.min(240, 24 + specEntries.length * 15));
summaryRow.eachCell((cell) => {
cell.alignment = { vertical: "middle", wrapText: true };
cell.border = {
top: { style: "thin", color: { argb: "FFDDE6F3" } },
bottom: { style: "thin", color: { argb: "FFDDE6F3" } },
left: { style: "thin", color: { argb: "FFDDE6F3" } },
right: { style: "thin", color: { argb: "FFDDE6F3" } },
};
});
if (product.image) {
const imageAsset = await getImageAsset(product.image);
if (imageAsset) {
const imageId = workbook.addImage(imageAsset);
summarySheet.addImage(imageId, {
tl: { col: 0.25, row: summaryRow.number - 1 + 0.08 },
ext: { width: CART_IMAGE_SIZE_PX, height: CART_IMAGE_SIZE_PX },
});
summaryRow.getCell(1).value = null;
} else {
summaryRow.getCell(1).value = "图片加载失败";
}
}
for (const [label, value] of specEntries.length > 0 ? specEntries : [["暂无参数", ""]]) {
const detailRow = detailsSheet.addRow({
name: product.name,
code: product.code,
category: product.category,
quantity: item.quantity,
label,
value,
});
detailRow.height = Math.max(24, Math.min(60, 18 + Math.ceil(String(value).length / 28) * 15));
detailRow.eachCell((cell) => {
cell.alignment = { vertical: "middle", wrapText: true };
cell.border = {
top: { style: "thin", color: { argb: "FFE5EBF3" } },
bottom: { style: "thin", color: { argb: "FFE5EBF3" } },
left: { style: "thin", color: { argb: "FFE5EBF3" } },
right: { style: "thin", color: { argb: "FFE5EBF3" } },
};
});
}
}
summarySheet.autoFilter = { from: "A1", to: `F${Math.max(1, summarySheet.rowCount)}` };
detailsSheet.autoFilter = { from: "A1", to: `F${Math.max(1, detailsSheet.rowCount)}` };
return workbook;
}
function downloadFile(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function isMobileDevice() {
return /Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}
type NativePdfImage = {
bytes: Uint8Array;
height: number;
name: string;
width: number;
};
type NativePdfPage = {
images: Set<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;
badge?: string;
subtitle?: string;
extraSpecs?: Array<[string, string]>;
};
function getSaveProductSpecs(product: CatalogProduct, extraSpecs: Array<[string, string]> = []) {
return [
...extraSpecs.filter(([, value]) => value),
...Object.entries(product.specs).filter(([, value]) => value),
];
}
function wrapCanvasText(context: CanvasRenderingContext2D, value: string, maxWidth: number, maxLines = 2) {
const characters = Array.from(String(value));
const lines: string[] = [];
let current = "";
for (const character of characters) {
const next = current + character;
if (current && context.measureText(next).width > maxWidth) {
lines.push(current);
current = character;
if (lines.length === maxLines) break;
} else {
current = next;
}
}
if (lines.length < maxLines && current) lines.push(current);
if (lines.length === maxLines && characters.join("").length > lines.join("").length) {
const last = lines[maxLines - 1] ?? "";
lines[maxLines - 1] = `${last.slice(0, Math.max(1, last.length - 1))}…`;
}
return lines;
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
fill: string,
stroke?: string,
) {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
context.fillStyle = fill;
context.fill();
if (stroke) {
context.strokeStyle = stroke;
context.lineWidth = 2;
context.stroke();
}
}
function loadProductCanvasImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`产品图片加载失败:${src}`));
image.src = src;
});
}
async function renderProductCardPng({ product, badge, subtitle, extraSpecs = [] }: SaveProductRequest) {
const specs = getSaveProductSpecs(product, extraSpecs);
const columnCount = 2;
const rowCount = Math.max(1, Math.ceil(specs.length / columnCount));
const cardWidth = 1200;
const padding = 52;
const headerHeight = 152;
const specCellHeight = 70;
const specGap = 10;
const mainHeight = Math.max(390, rowCount * specCellHeight + (rowCount - 1) * specGap + 28);
const cardHeight = padding + headerHeight + 24 + mainHeight + 24 + 42 + padding;
const canvas = document.createElement("canvas");
canvas.width = cardWidth;
canvas.height = cardHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("当前浏览器不支持图片生成");
context.fillStyle = "#eef3fa";
context.fillRect(0, 0, cardWidth, cardHeight);
drawRoundedRect(context, 18, 18, cardWidth - 36, cardHeight - 36, 26, "#ffffff", "#d9e3f2");
context.fillStyle = "#185ee8";
context.fillRect(padding, padding, 110, 7);
context.fillStyle = "#123267";
context.font = "800 25px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("GAUDELOT", padding, padding + 34);
context.fillStyle = "#7b8ca8";
context.font = "600 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("高徳乐产品资料", padding + 145, padding + 33);
if (badge) {
const badgeWidth = Math.max(100, context.measureText(badge).width + 34);
drawRoundedRect(context, cardWidth - padding - badgeWidth, padding + 8, badgeWidth, 32, 16, "#edf4ff");
context.fillStyle = "#185ee8";
context.font = "700 14px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText(badge, cardWidth - padding - badgeWidth + 17, padding + 29);
}
context.fillStyle = "#111d34";
context.font = "800 30px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
const titleLines = wrapCanvasText(context, product.name, 760, 2);
titleLines.forEach((line, index) => context.fillText(line, padding, padding + 82 + index * 36));
context.fillStyle = "#71809a";
context.font = "600 17px ui-monospace, SFMono-Regular, Menlo, monospace";
context.fillText(product.code, padding, padding + 145);
if (subtitle) {
context.fillStyle = "#8290a5";
context.font = "500 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText(subtitle, padding + 240, padding + 145);
}
const mainY = padding + headerHeight + 24;
const imageX = padding;
const imageWidth = 430;
const infoX = imageX + imageWidth + 24;
const infoWidth = cardWidth - padding - infoX;
drawRoundedRect(context, imageX, mainY, imageWidth, mainHeight, 18, "#f3f7fd", "#dce6f3");
drawRoundedRect(context, infoX, mainY, infoWidth, mainHeight, 18, "#f8faff", "#dce6f3");
try {
if (!product.image) throw new Error("没有产品主图");
const image = await loadProductCanvasImage(product.image);
const maxImageWidth = imageWidth - 34;
const maxImageHeight = mainHeight - 34;
const scale = Math.min(maxImageWidth / image.naturalWidth, maxImageHeight / image.naturalHeight);
const imageWidthOnCanvas = image.naturalWidth * scale;
const imageHeightOnCanvas = image.naturalHeight * scale;
context.drawImage(
image,
imageX + (imageWidth - imageWidthOnCanvas) / 2,
mainY + (mainHeight - imageHeightOnCanvas) / 2,
imageWidthOnCanvas,
imageHeightOnCanvas,
);
} catch {
context.fillStyle = "#9eb3d3";
context.font = "800 22px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("GAUDELOT", imageX + 132, mainY + mainHeight / 2);
}
context.fillStyle = "#6f809b";
context.font = "700 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("主要参数", infoX + 22, mainY + 31);
const cellsTop = mainY + 48;
const cellWidth = (infoWidth - 44 - specGap) / columnCount;
specs.forEach(([label, value], index) => {
const column = index % columnCount;
const row = Math.floor(index / columnCount);
const cellX = infoX + 22 + column * (cellWidth + specGap);
const cellY = cellsTop + row * (specCellHeight + specGap);
drawRoundedRect(context, cellX, cellY, cellWidth, specCellHeight, 10, "#ffffff", "#e4ebf4");
context.fillStyle = "#8b98ab";
context.font = "600 13px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
const labelLines = wrapCanvasText(context, label, cellWidth - 24, 1);
context.fillText(labelLines[0] ?? label, cellX + 12, cellY + 22);
context.fillStyle = "#15233d";
context.font = "800 16px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
const valueLines = wrapCanvasText(context, value, cellWidth - 24, 2);
valueLines.forEach((line, lineIndex) => context.fillText(line, cellX + 12, cellY + 47 + lineIndex * 18));
});
context.fillStyle = "#8b98ab";
context.font = "500 13px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
context.fillText("高徳乐官方产品目录 · 仅供选型参考,具体参数以正式文件为准", padding, cardHeight - padding - 11);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("PNG生成失败"))), "image/png");
});
}
*/
export default function Home() {
const [stage, setStage] = useState(0);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [requirements, setRequirements] = useState(DEFAULT_REQUIREMENTS);
const [showSampleForm, setShowSampleForm] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [leadId, setLeadId] = useState("");
const [catalogQuery, setCatalogQuery] = useState("");
const [catalogCategory, setCatalogCategory] = useState("全部产品");
const [catalogScale, setCatalogScale] = useState("all");
const [catalogLimit, setCatalogLimit] = useState(24);
const [catalogToolbarCompact, setCatalogToolbarCompact] = useState(false);
const [catalogToolbarFiltersOpen, setCatalogToolbarFiltersOpen] = useState(false);
const [catalogToolbarHidden, setCatalogToolbarHidden] = useState(false);
const [precisionGearQuery, setPrecisionGearQuery] = useState("");
const [precisionGearCategory, setPrecisionGearCategory] = useState("全部子类");
const [precisionGearLimit, setPrecisionGearLimit] = useState(24);
const [precisionGearToolbarCompact, setPrecisionGearToolbarCompact] = useState(false);
const [precisionGearFiltersOpen, setPrecisionGearFiltersOpen] = useState(false);
const [selectedCatalogProduct, setSelectedCatalogProduct] = useState<CatalogProduct | null>(null);
const [catalogVisualMode, setCatalogVisualMode] = useState<CatalogVisualMode>("main");
const [cartItems, setCartItems] = useState<CartItem[]>(() => {
if (typeof window === "undefined") return [];
try {
const storedCart = window.localStorage.getItem(CART_STORAGE_KEY);
const parsedCart = storedCart ? JSON.parse(storedCart) as CartItem[] : [];
return Array.isArray(parsedCart) ? parsedCart : [];
} catch {
return [];
}
});
const [cartOpen, setCartOpen] = useState(false);
const [cartMessage, setCartMessage] = useState("");
const [exportingCart, setExportingCart] = useState(false);
const [exportingPdf, setExportingPdf] = useState(false);
const [cartSampleFormOpen, setCartSampleFormOpen] = useState(false);
const [cartSampleSubmitted, setCartSampleSubmitted] = useState(false);
const [cartSampleLeadId, setCartSampleLeadId] = useState("");
const matches = useMemo(() => getMatches(requirements), [requirements]);
const topMatches = matches.slice(0, 3);
const top = topMatches[0];
const compatibleCount = matches.filter((match) => match.coreCompatible).length;
const filteredCatalog = useMemo(() => {
const query = catalogQuery.trim().toLowerCase();
return CATALOG_PRODUCTS.filter((product) => {
const productScale = getProductScale(product)?.key ?? "unmarked";
if (catalogScale !== "all" && productScale !== catalogScale) return false;
const matchesCategory =
catalogCategory === "全部产品" ||
product.category === catalogCategory ||
(catalogCategory === SILENT_SERVO_CATEGORY && isSilentServo(product));
if (!matchesCategory) return false;
if (!query) return true;
const haystack = [
product.name,
product.code,
product.category,
...Object.keys(product.specs),
...Object.values(product.specs),
]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [catalogCategory, catalogQuery, catalogScale]);
const visibleCatalog = filteredCatalog.slice(0, catalogLimit);
const filteredPrecisionGears = useMemo(() => {
const query = precisionGearQuery.trim().toLowerCase();
return PRECISION_GEAR_PRODUCTS.filter((product) => {
if (precisionGearCategory !== "全部子类" && product.subCategory !== precisionGearCategory) return false;
if (!query) return true;
const haystack = [
product.name,
product.code,
product.subCategory,
...Object.keys(product.specs),
...Object.values(product.specs),
].join(" ").toLowerCase();
return haystack.includes(query);
});
}, [precisionGearCategory, precisionGearQuery]);
const visiblePrecisionGears = filteredPrecisionGears.slice(0, precisionGearLimit);
const needsEngineer =
!top?.coreCompatible ||
requirements.annualVolume === "50000件以上" ||
Number(requirements.firstOrderQuantity) >= 1000 ||
requirements.application === "工业设备";
const selectedCatalogDimensionImage = selectedCatalogProduct
? getCatalogProductDimensionImage(selectedCatalogProduct)
: null;
const cartCount = cartItems.reduce((total, item) => total + item.quantity, 0);
useEffect(() => {
function updateCatalogToolbarState() {
const toolbar = document.querySelector(".catalog-toolbar");
const shouldCompact = window.innerWidth <= 720 && Boolean(toolbar) && toolbar.getBoundingClientRect().top <= 72 && window.scrollY > 0;
setCatalogToolbarCompact((current) => (current === shouldCompact ? current : shouldCompact));
if (!shouldCompact) setCatalogToolbarFiltersOpen(false);
const precisionHeading = document.querySelector(".precision-gear-catalog-heading");
const shouldHideCatalogToolbar = window.innerWidth <= 720 && Boolean(precisionHeading) && precisionHeading.getBoundingClientRect().top <= 72;
setCatalogToolbarHidden((current) => (current === shouldHideCatalogToolbar ? current : shouldHideCatalogToolbar));
const precisionToolbar = document.querySelector(".precision-gear-controls");
const shouldCompactPrecision = window.innerWidth <= 720 && Boolean(precisionToolbar) && precisionToolbar.getBoundingClientRect().top <= 72 && window.scrollY > 0;
setPrecisionGearToolbarCompact((current) => (current === shouldCompactPrecision ? current : shouldCompactPrecision));
if (!shouldCompactPrecision) setPrecisionGearFiltersOpen(false);
}
updateCatalogToolbarState();
window.addEventListener("scroll", updateCatalogToolbarState, { passive: true });
document.addEventListener("scroll", updateCatalogToolbarState, { passive: true, capture: true });
window.addEventListener("resize", updateCatalogToolbarState);
return () => {
window.removeEventListener("scroll", updateCatalogToolbarState);
document.removeEventListener("scroll", updateCatalogToolbarState, true);
window.removeEventListener("resize", updateCatalogToolbarState);
};
}, []);
useEffect(() => {
try {
window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartItems));
} catch (error) {
console.warn("保存购物车失败", error);
}
}, [cartItems]);
function openCatalogProduct(product: CatalogProduct) {
setSelectedCatalogProduct(product);
setCatalogVisualMode("main");
}
function addToCart(product: CatalogProduct, source = product.category) {
setCartItems((current) => {
const existingItem = current.find((item) => item.product.id === product.id);
if (existingItem) {
return current.map((item) => item.product.id === product.id
? { ...item, quantity: item.quantity + 1, source }
: item);
}
return [...current, { product, quantity: 1, source }];
});
setCartMessage(`${product.name} 已加入购物车`);
setCartOpen(true);
window.setTimeout(() => setCartMessage(""), 2200);
}
function requestSpecSheet(product: CatalogProduct) {
setCartMessage(`${product.name} 的产品规格书资料待补充`);
window.setTimeout(() => setCartMessage(""), 2400);
}
function addTopMatchesToCart() {
if (topMatches.length === 0) return;
setCartItems((current) => {
const next = [...current];
topMatches.forEach((match) => {
const existingIndex = next.findIndex((item) => item.product.id === match.product.id);
if (existingIndex >= 0) {
const existingItem = next[existingIndex];
next[existingIndex] = {
...existingItem,
quantity: existingItem.quantity + 1,
source: "AI初选结果",
};
} else {
next.push({ product: match.product, quantity: 1, source: "AI初选结果" });
}
});
return next;
});
setCartMessage(`${topMatches.length} 款 AI 推荐型号已加入购物车`);
setCartOpen(true);
window.setTimeout(() => setCartMessage(""), 2400);
}
function updateCartQuantity(productId: string, quantity: number) {
if (quantity <= 0) {
setCartItems((current) => current.filter((item) => item.product.id !== productId));
return;
}
setCartItems((current) => current.map((item) => item.product.id === productId ? { ...item, quantity } : item));
}
function clearCart() {
setCartItems([]);
setCartSampleFormOpen(false);
setCartSampleSubmitted(false);
}
function openCartSampleForm() {
if (cartItems.length === 0) return;
setCartSampleSubmitted(false);
setCartSampleFormOpen(true);
}
function submitCartSampleRequest(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const id = `GDL-S-${new Date().toISOString().slice(5, 10).replace("-", "")}-${String(Date.now()).slice(-4)}`;
setCartSampleLeadId(id);
setCartSampleSubmitted(true);
}
async function saveCartList() {
if (cartItems.length === 0 || exportingCart) return;
setExportingCart(true);
setCartMessage("正在生成产品清单…");
try {
const workbook = await buildCartWorkbook(cartItems);
const buffer = await workbook.xlsx.writeBuffer();
const filename = getCartExportFilename();
const file = new File([buffer as BlobPart], filename, { type: CART_EXPORT_MIME });
if (isMobileDevice() && typeof navigator.share === "function") {
const canShareFiles = typeof navigator.canShare !== "function" || navigator.canShare({ files: [file] });
if (canShareFiles) {
try {
await navigator.share({
title: "高徳乐产品清单",
text: "购物车产品清单",
files: [file],
});
setCartMessage("已打开系统分享面板");
return;
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
}
}
}
downloadFile(file, filename);
setCartMessage("产品清单已下载");
} catch (error) {
console.error("生成购物车清单失败", error);
setCartMessage("产品清单生成失败,请稍后重试");
} finally {
setExportingCart(false);
window.setTimeout(() => setCartMessage(""), 2400);
}
}
async function exportCartPdf() {
if (cartItems.length === 0 || exportingPdf) return;
setExportingPdf(true);
setCartMessage("正在生成 PDF…");
try {
const blob = await buildCartPdf(cartItems);
const filename = getCartPdfFilename();
const file = new File([blob], filename, { type: PDF_MIME });
if (isMobileDevice() && typeof navigator.share === "function") {
const canShareFiles = typeof navigator.canShare !== "function" || navigator.canShare({ files: [file] });
if (canShareFiles) {
try {
await navigator.share({
title: "高徳乐产品清单",
text: "购物车产品清单 PDF",
files: [file],
});
setCartMessage("已打开系统分享面板");
return;
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
}
}
}
downloadFile(file, filename);
setCartMessage("PDF 文件已下载");
} catch (error) {
console.error("生成购物车 PDF 失败", error);
setCartMessage("PDF 生成失败,请稍后重试");
} finally {
setExportingPdf(false);
window.setTimeout(() => setCartMessage(""), 2400);
}
}
function startSelection() {
setMobileMenuOpen(false);
setStage(1);
requestAnimationFrame(() => {
document.getElementById("selector")?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
function update<K extends keyof Requirements>(key: K, value: Requirements[K]) {
setRequirements((current) => ({ ...current, [key]: value }));
}
function reset() {
setStage(1);
setRequirements(DEFAULT_REQUIREMENTS);
setShowSampleForm(false);
setSubmitted(false);
}
function submitLead(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const id = `GDL-${new Date().toISOString().slice(5, 10).replace("-", "")}-${String(
Date.now(),
).slice(-4)}`;
setLeadId(id);
setSubmitted(true);
}
return (
<main>
{cartMessage && (
<div className="cart-toast" role="status" aria-live="polite">
{cartMessage}
</div>
)}
{cartOpen && (
<div
className="cart-drawer-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setCartOpen(false);
}}
>
<aside className="cart-drawer" role="dialog" aria-modal="true" aria-labelledby="cart-title">
<div className="cart-drawer-header">
<div>
<p className="step-kicker"></p>
<h2 id="cart-title"> <span>{cartCount}</span></h2>
</div>
<button className="cart-close-button" type="button" onClick={() => setCartOpen(false)} aria-label="关闭购物车">×</button>
</div>
{cartItems.length > 0 ? (
cartSampleFormOpen ? (
<div className="cart-sample-scroll">
{!cartSampleSubmitted ? (
<form className="sample-form cart-sample-form" onSubmit={submitCartSampleRequest}>
<div>
<p className="step-kicker"></p>
<h3></h3>
<p> {cartItems.length} {cartCount} </p>
</div>
<div className="cart-sample-products">
{cartItems.map((item) => (
<div key={`sample-${item.product.id}`}>
<span>{item.product.name}</span>
<code>{item.product.code} · {item.quantity}</code>
</div>
))}
</div>
<div className="form-grid">
<label><span></span><input required name="company" placeholder="请输入公司名称" /></label>
<label><span></span><input required name="name" placeholder="姓名" /></label>
<label><span></span><input required name="phone" inputMode="tel" placeholder="用于样品确认" /></label>
<label><span></span><input name="email" type="email" placeholder="接收申请结果" /></label>
</div>
<label className="full-field"><span></span><textarea name="notes" placeholder="收货信息、使用场景、测试时间或其他要求" /></label>
<div className="cart-sample-form-actions">
<button className="outline-button" type="button" onClick={() => setCartSampleFormOpen(false)}></button>
<button className="panel-primary" type="submit"> <span></span></button>
</div>
<small className="demo-disclaimer"></small>
</form>
) : (
<div className="success-card cart-sample-success">
<div className="success-icon"></div>
<p className="step-kicker"></p>
<h3> {cartSampleLeadId}</h3>
<p> {cartCount} 线</p>
<button type="button" className="outline-button" onClick={() => setCartSampleFormOpen(false)}></button>
</div>
)}
</div>
) : (
<>
<div className="cart-list">
{cartItems.map((item) => (
<div className="cart-item" key={item.product.id}>
<div className="cart-item-image">
{item.product.image ? <img src={getCatalogProductThumbnail(item.product.image)} alt="" /> : <span>G</span>}
</div>
<div className="cart-item-copy">
<small>{item.source}</small>
<strong>{item.product.name}</strong>
<code>{item.product.code}</code>
<div className="cart-item-actions">
<div className="quantity-control" aria-label={`${item.product.name}数量`}>
<button type="button" onClick={() => updateCartQuantity(item.product.id, item.quantity - 1)} aria-label="减少数量"></button>
<span>{item.quantity}</span>
<button type="button" onClick={() => updateCartQuantity(item.product.id, item.quantity + 1)} aria-label="增加数量"></button>
</div>
<button className="cart-remove-button" type="button" onClick={() => updateCartQuantity(item.product.id, 0)}></button>
</div>
</div>
</div>
))}
</div>
<div className="cart-drawer-footer">
<p> {cartCount} </p>
<div>
<button className="outline-button" type="button" onClick={clearCart}></button>
<button
className="outline-button cart-export-button"
type="button"
onClick={saveCartList}
disabled={cartItems.length === 0 || exportingCart}
aria-label={exportingCart ? "正在生成产品清单" : "保存购物车产品列表"}
>
{exportingCart ? "生成中…" : "保存列表"}
</button>
<button
className="outline-button cart-pdf-button"
type="button"
onClick={exportCartPdf}
disabled={cartItems.length === 0 || exportingPdf}
aria-label={exportingPdf ? "正在生成购物车 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>
</div>
</div>
</>
)
) : (
<div className="cart-empty">
<span>🛒</span>
<strong></strong>
<p> AI </p>
<div className="cart-empty-actions">
<button className="panel-primary" type="button" onClick={() => setCartOpen(false)}> <span></span></button>
<button className="outline-button cart-export-button" type="button" onClick={saveCartList} disabled aria-label="购物车为空,保存列表不可用"></button>
<button className="outline-button cart-pdf-button" type="button" onClick={exportCartPdf} disabled aria-label="购物车为空,导出 PDF 不可用"> PDF</button>
<button className="panel-primary cart-sample-button" type="button" onClick={openCartSampleForm} disabled aria-label="购物车为空,购买样品申请不可用"> <span></span></button>
</div>
</div>
)}
</aside>
</div>
)}
<section className="cart-print-sheet" aria-hidden="true">
<div className="cart-print-header">
<div>
<p>GAUDELOT · </p>
<h1></h1>
<span> {cartItems.length} / {cartCount} </span>
</div>
<strong>{getCartPdfFilename()}</strong>
</div>
<table className="cart-print-summary">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{cartItems.map((item) => (
<tr key={`print-summary-${item.product.id}`}>
<td className="cart-print-image-cell">
{item.product.image ? <img src={item.product.image} alt={item.product.name} /> : "暂无图片"}
</td>
<td>{item.product.name}</td>
<td>{item.product.code}</td>
<td>{item.product.category}</td>
<td>{item.quantity}</td>
<td className="cart-print-preline">{getKeySpecText(item.product)}</td>
<td>{item.product.specs["产品尺寸"] ?? item.product.specs["外形尺寸"] ?? "—"}</td>
</tr>
))}
</tbody>
</table>
<h2></h2>
<div className="cart-print-details">
{cartItems.map((item) => (
<section className="cart-print-product" key={`print-detail-${item.product.id}`}>
<div className="cart-print-product-heading">
<div>
<h3>{item.product.name}</h3>
<span>{item.product.code} · {item.product.category} · {item.quantity}</span>
</div>
</div>
<div className="cart-print-spec-grid">
{getFullSpecEntries(item.product).map(([label, value]) => (
<div key={`${item.product.id}-${label}`}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</section>
))}
</div>
<p className="cart-print-footer"> AI · </p>
</section>
<header className="site-header">
<a className="brand" href="#top" aria-label="高徳乐 AI选型首页">
<span className="brand-wordmark">
<strong>GAUDELOT</strong>
<small></small>
</span>
</a>
<nav id="mobile-primary-nav" className={mobileMenuOpen ? "is-open" : ""} aria-label="主导航">
<a href="#selector" onClick={() => setMobileMenuOpen(false)}>AI选型</a>
<a href="#products" onClick={() => setMobileMenuOpen(false)}></a>
<a href="#precision-gears" onClick={() => setMobileMenuOpen(false)}>齿</a>
<a href="#how" onClick={() => setMobileMenuOpen(false)}></a>
</nav>
<div className="header-actions">
<button className="cart-header-button" type="button" onClick={() => setCartOpen(true)} aria-label={`打开购物车,当前${cartCount}件产品`}>
<span aria-hidden="true">🛒</span>
<small></small>
<b>{cartCount}</b>
</button>
<button className="header-cta" type="button" onClick={startSelection}>
<span></span>
</button>
<button
className="mobile-menu-toggle"
type="button"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-primary-nav"
onClick={() => setMobileMenuOpen((open) => !open)}
>
<span aria-hidden="true">{mobileMenuOpen ? "×" : "☰"}</span>
<small>{mobileMenuOpen ? "关闭" : "菜单"}</small>
</button>
</div>
</header>
<section className="hero" id="top">
<div className="hero-copy">
<div className="eyebrow"><span /> AI · </div>
<h1>
3<br />
<em></em>
</h1>
<p className="hero-lead">
AI从高徳乐完整舵机目录中生成候选方案
</p>
<div className="hero-actions">
<button className="primary-action" type="button" onClick={startSelection}>
<span></span>
</button>
<a href="#products" className="text-action"></a>
</div>
<div className="trust-row">
<span><b>243</b> </span>
<span><b>0.09920</b> kgf·cm</span>
<span><b>48h</b> </span>
</div>
</div>
<div className="selector-shell" id="selector">
<div className="selector-topbar">
<div>
<span className="live-dot" /> AI
</div>
<span className="secure-note"></span>
</div>
{stage === 0 && (
<div className="welcome-panel">
<div className="assistant-avatar">AI</div>
<p className="assistant-label"></p>
<h2></h2>
<p>3</p>
<button className="panel-primary" type="button" onClick={startSelection}>
<span>3</span>
</button>
<div className="privacy-note"> </div>
</div>
)}
{stage > 0 && stage < 4 && (
<div className="question-panel">
<div className="progress-wrap" aria-label={`选型进度 ${stage}/3`}>
<div className="progress-labels">
<span></span>
<strong>{stage} / 3</strong>
</div>
<div className="progress-track"><i style={{ width: `${stage * 33.33}%` }} /></div>
</div>
{stage === 1 && (
<div className="question-step">
<p className="step-kicker"> · 使</p>
<h2></h2>
<p className="step-help"></p>
<div className="choice-grid">
{APPLICATIONS.map((item) => (
<button
type="button"
key={item.name}
className={`choice-card ${requirements.application === item.name ? "selected" : ""}`}
onClick={() => update("application", item.name)}
>
<b>{item.name}</b>
<small>{item.note}</small>
<span className="choice-check"></span>
</button>
))}
</div>
</div>
)}
{stage === 2 && (
<div className="question-step">
<p className="step-kicker"> · </p>
<h2></h2>
<p className="step-help">2430.09920 kgf·cm</p>
<div className="two-fields compact-fields">
<label className="select-field">
<span></span>
<select
value={requirements.category}
onChange={(event) => update("category", event.target.value)}
>
<option value="全部舵机">243</option>
<optgroup label="官方产品系列">
{SERVO_CATEGORIES.map((category) => (
<option key={category} value={category}>
{category}{getCategoryCount(category)}
</option>
))}
</optgroup>
<optgroup label="特色筛选与定制">
{[SILENT_SERVO_CATEGORY, CUSTOM_SERVO_CATEGORY].map((category) => (
<option key={category} value={category}>
{category === CUSTOM_SERVO_CATEGORY
? `${category}(联系我们)`
: `${category}${getCategoryCount(category)}款)`}
</option>
))}
</optgroup>
</select>
</label>
<label className="select-field">
<span></span>
<select
value={requirements.voltage}
onChange={(event) => update("voltage", Number(event.target.value))}
>
{RATED_VOLTAGES.map((voltage) => (
<option key={voltage} value={voltage}>{voltage.toFixed(1)}V</option>
))}
</select>
</label>
</div>
<div className="field-stack">
<div className="field-label">
<label htmlFor="torque"></label>
<span className="torque-entry">
<input
aria-label="最低堵转扭矩精确值"
type="number"
min={TORQUE_MIN}
max={TORQUE_MAX}
step="0.01"
value={requirements.torque}
onChange={(event) => update(
"torque",
Math.min(TORQUE_MAX, Math.max(TORQUE_MIN, Number(event.target.value) || TORQUE_MIN)),
)}
/>
<strong>kgf·cm</strong>
</span>
</div>
<input
id="torque"
className="range-input"
type="range"
min="0"
max="100"
step="0.1"
value={torqueToSlider(requirements.torque)}
onChange={(event) => update("torque", sliderToTorque(Number(event.target.value)))}
/>
<div className="range-scale"><span>0.09</span><span>1</span><span>10</span><span>100</span><span>920</span></div>
<div className="torque-presets" aria-label="常用扭矩快捷值">
{TORQUE_PRESETS.map((torque) => (
<button
key={torque}
type="button"
className={requirements.torque === torque ? "active" : ""}
onClick={() => update("torque", torque)}
>{formatTorque(torque)}</button>
))}
</div>
</div>
<div className="parameter-grid">
<label className="select-field">
<span></span>
<select
value={requirements.weightLimit}
onChange={(event) => update("weightLimit", Number(event.target.value))}
>
<option value={0}></option>
{[2, 5, 10, 20, 40, 100, 250, 500, 1000, 2000].map((weight) => (
<option key={weight} value={weight}>{weight}g以内</option>
))}
</select>
</label>
<label className="select-field">
<span></span>
<select
value={requirements.angle}
onChange={(event) => update("angle", event.target.value)}
>
<option></option>
<option>135°</option>
<option>180°</option>
<option>270330°</option>
<option>360°/</option>
</select>
</label>
<label className="select-field">
<span></span>
<select
value={requirements.control}
onChange={(event) => update("control", event.target.value)}
>
<option></option>
<option>PWM/线</option>
<option>/TTL</option>
<option>RS485</option>
<option>CAN</option>
<option>SBUS</option>
<option>//线</option>
</select>
</label>
<label className="select-field">
<span>齿</span>
<select
value={requirements.gear}
onChange={(event) => update("gear", event.target.value)}
>
<option></option>
<option>齿</option>
<option>齿</option>
<option>齿</option>
</select>
</label>
</div>
</div>
)}
{stage === 3 && (
<div className="question-step">
<p className="step-kicker"> · </p>
<h2></h2>
<p className="step-help"></p>
<div className="two-fields">
<label className="select-field">
<span></span>
<select
value={requirements.annualVolume}
onChange={(event) => update("annualVolume", event.target.value)}
>
<option>500</option>
<option>5005000</option>
<option>500050000</option>
<option>50000</option>
</select>
</label>
<label className="select-field">
<span></span>
<input
className="parameter-input"
type="number"
min="1"
step="1"
inputMode="numeric"
value={requirements.firstOrderQuantity}
onChange={(event) => update("firstOrderQuantity", event.target.value)}
placeholder="例如500"
/>
</label>
</div>
<div className="two-fields">
<label className="select-field">
<span></span>
<select
value={requirements.painPoint}
onChange={(event) => update("painPoint", event.target.value)}
>
<option></option>
<option></option>
<option></option>
<option></option>
<option></option>
<option></option>
</select>
</label>
<label className="select-field">
<span>使</span>
<input
className="parameter-input"
value={requirements.currentModel}
onChange={(event) => update("currentModel", event.target.value)}
placeholder="例如SG90 / DS-E001"
/>
</label>
</div>
<label className="text-field project-notes">
<span></span>
<textarea
value={requirements.otherRequirements}
onChange={(event) => update("otherRequirements", event.target.value)}
placeholder="例如:防水等级、噪音、寿命、认证、安装尺寸、线长或交期要求"
/>
</label>
</div>
)}
<div className="panel-nav">
<button className="back-button" type="button" onClick={() => setStage(stage - 1)}> </button>
<button
className="next-button"
type="button"
disabled={stage === 1 && !requirements.application}
onClick={() => setStage(stage + 1)}
>
{stage === 3 ? "生成选型方案" : "继续"} <span></span>
</button>
</div>
</div>
)}
{stage === 4 && requirements.category === CUSTOM_SERVO_CATEGORY && (
<div className="result-panel custom-result-panel">
<div className="result-heading">
<div>
<p className="step-kicker"></p>
<h2></h2>
</div>
<button type="button" className="restart-button" onClick={reset}></button>
</div>
<div className="custom-result-card">
<div className="custom-result-mark">ODM</div>
<div>
<span></span>
<h3></h3>
<p>线</p>
</div>
<div className="metric-grid custom-requirement-grid">
<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.firstOrderQuantity ? `${requirements.firstOrderQuantity}` : "待确认"} />
<Metric label="年度用量" value={requirements.annualVolume} />
</div>
{requirements.otherRequirements && (
<p className="custom-notes"><strong></strong>{requirements.otherRequirements}</p>
)}
</div>
{!showSampleForm && (
<div className="result-actions">
<button className="panel-primary" type="button" onClick={() => setShowSampleForm(true)}>
· <span></span>
</button>
<button className="outline-button" type="button" onClick={() => window.print()}> / </button>
</div>
)}
{showSampleForm && !submitted && (
<form className="sample-form custom-contact-form" onSubmit={submitLead}>
<div>
<p className="step-kicker"></p>
<h3></h3>
<p>CRM并通知工程师跟进</p>
</div>
<div className="form-grid">
<label><span></span><input required name="company" placeholder="请输入公司名称" /></label>
<label><span></span><input required name="name" placeholder="姓名" /></label>
<label><span></span><input required name="phone" inputMode="tel" placeholder="用于方案确认" /></label>
<label><span></span><input name="email" type="email" placeholder="接收定制方案" /></label>
</div>
<label className="full-field">
<span></span>
<textarea name="notes" defaultValue={requirements.otherRequirements} placeholder="请补充结构图、安装空间、认证或交期要求" />
</label>
<button className="panel-primary" type="submit"> <span></span></button>
<small className="demo-disclaimer"></small>
</form>
)}
{submitted && (
<div className="success-card">
<div className="success-icon"></div>
<p className="step-kicker"></p>
<h3> {leadId}</h3>
<p>线</p>
<button type="button" className="outline-button" onClick={reset}></button>
</div>
)}
</div>
)}
{stage === 4 && requirements.category !== CUSTOM_SERVO_CATEGORY && top && (
<div className="result-panel">
<div className="result-heading">
<div>
<p className="step-kicker">AI </p>
<h2>{compatibleCount > 0 ? `找到 ${compatibleCount} 个匹配型号` : "暂无完全匹配型号"}</h2>
</div>
<button type="button" className="restart-button" onClick={reset}></button>
</div>
<div className="recommendation-grid">
{topMatches.map((match, index) => (
<article className={`recommendation-card ${index === 0 ? "is-primary" : ""}`} key={match.product.id}>
<button
type="button"
className="recommendation-image-wrap"
onClick={() => openCatalogProduct(match.product)}
aria-label={`查看${match.product.name}大图`}
>
<img src={getCatalogProductThumbnail(match.product.image)} alt={match.product.name} />
<span className="match-badge">
{index === 0 ? (match.coreCompatible ? "首选推荐" : "待复核候选") : `${match.confidence}% 匹配`}
</span>
<span className="recommendation-zoom-hint"></span>
</button>
<div className="recommendation-content">
<p className="product-code">{match.product.code}</p>
<h3>{match.product.name}</h3>
<p className="product-subtitle">{match.product.category} · {match.product.size || "尺寸待复核"}</p>
<div className="metric-grid">
<Metric label="堵转扭矩" value={`${formatTorque(match.product.torque)} kgf·cm`} />
<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.controlProtocol} />
<Metric label="端子 / 连接线" value={match.product.terminalConnection} />
<Metric label="齿轮材质" value={match.product.gear} />
</div>
<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>
<small></small>
</div>
<button
className="add-cart-button"
type="button"
onClick={() => addToCart(match.product, "AI初选结果")}
aria-label={`${match.product.name}加入购物车`}
>
<span></span>
</button>
<button
className="spec-sheet-button"
type="button"
onClick={() => requestSpecSheet(match.product)}
aria-label={`下载${match.product.name}产品规格书`}
>
<span></span>
</button>
</div>
</article>
))}
</div>
{needsEngineer && (
<div className="engineer-alert">
<span></span>
<p>{compatibleCount === 0
? "当前条件没有完全匹配的目录型号,建议调整电压、扭矩余量或发起定制评估。"
: "你的项目包含高用量、工业场景或边界参数,系统已标记为优先人工复核。"}</p>
</div>
)}
{!showSampleForm && (
<div className="result-actions">
<button className="panel-primary" type="button" onClick={() => setShowSampleForm(true)}>
<span></span>
</button>
<button className="outline-button" type="button" onClick={addTopMatchesToCart}></button>
</div>
)}
{showSampleForm && !submitted && (
<form className="sample-form" onSubmit={submitLead}>
<div>
<p className="step-kicker"></p>
<h3></h3>
<p>线CRM</p>
</div>
<div className="form-grid">
<label><span></span><input required name="company" placeholder="请输入公司名称" /></label>
<label><span></span><input required name="name" placeholder="姓名" /></label>
<label><span></span><input required name="phone" inputMode="tel" placeholder="用于样品确认" /></label>
<label><span></span><select name="quantity"><option>12</option><option>35</option><option>610</option></select></label>
</div>
<label className="full-field"><span></span><textarea name="notes" placeholder="安装尺寸、线长、角度、控制协议或测试时间要求" /></label>
<button className="panel-primary" type="submit"> <span></span></button>
<small className="demo-disclaimer"></small>
</form>
)}
{submitted && (
<div className="success-card">
<div className="success-icon"></div>
<p className="step-kicker">线</p>
<h3> {leadId}</h3>
<p>{top.product.name}线CRM并触发技术复核</p>
<button type="button" className="outline-button" onClick={reset}></button>
</div>
)}
</div>
)}
</div>
</section>
<section className="capability-strip" aria-label="高徳乐能力">
<div><strong>2</strong><span></span></div>
<div><strong>137</strong><span></span></div>
<div><strong>1/</strong><span>线</span></div>
<div><strong>IATF 16949</strong><span></span></div>
</section>
<section className="products-section" id="products">
<div className="section-heading">
<div>
<p className="eyebrow"><span /> · 2026.06</p>
<h2>{CATALOG_PRODUCTS.length}</h2>
</div>
<p>{CATALOG_PRODUCTS.length}</p>
</div>
<div className="catalog-directory">
<div className="catalog-summary" aria-label="产品分类统计">
{PRIMARY_CATALOG_CATEGORIES.map((category) => (
<button
type="button"
key={category}
className={catalogCategory === category ? "active" : ""}
onClick={() => {
setCatalogCategory(category);
setCatalogLimit(24);
}}
>
<strong>{getCategoryCount(category)}</strong>
<span>{category}</span>
</button>
))}
</div>
<p className="catalog-category-note"> · {CATALOG_PRODUCTS.length} · 6 · 11</p>
<div className={`catalog-toolbar ${catalogToolbarCompact ? "is-compact" : ""} ${catalogToolbarFiltersOpen ? "filters-open" : ""} ${catalogToolbarHidden ? "is-hidden" : ""}`}>
<label className="catalog-search">
<span></span>
<input
value={catalogQuery}
onChange={(event) => {
setCatalogQuery(event.target.value);
setCatalogLimit(24);
}}
placeholder="搜索型号、编码、扭矩、协议或参数"
aria-label="搜索产品目录"
/>
{catalogQuery && (
<button type="button" onClick={() => setCatalogQuery("")} aria-label="清空搜索">×</button>
)}
</label>
<button
className="catalog-mobile-filter-toggle"
type="button"
aria-expanded={catalogToolbarFiltersOpen}
onClick={() => setCatalogToolbarFiltersOpen((open) => !open)}
>
{catalogToolbarFiltersOpen ? "收起" : "筛选"}
</button>
<div className="category-pills" aria-label="产品类别筛选">
{CATALOG_FILTER_CATEGORIES.map((category) => (
<button
type="button"
key={category}
className={`${catalogCategory === category ? "active" : ""} ${category === SILENT_SERVO_CATEGORY ? "feature-filter" : ""}`}
onClick={() => {
setCatalogCategory(category);
setCatalogLimit(24);
}}
>{category === SILENT_SERVO_CATEGORY ? `${category} · ${SILENT_SERVO_COUNT}` : category}</button>
))}
</div>
<div className="catalog-scale-filter">
<label>
<span> g / kg </span>
<select
value={catalogScale}
onChange={(event) => {
setCatalogScale(event.target.value);
setCatalogLimit(24);
}}
>
<option value="all"> g / kg </option>
<optgroup label="克重系列g / 克)">
{GRAM_SCALE_OPTIONS.map((option) => (
<option key={option.key} value={option.key}>{option.label}{option.count}</option>
))}
</optgroup>
<optgroup label="千克扭矩等级kg / 千克)">
{KILOGRAM_SCALE_OPTIONS.map((option) => (
<option key={option.key} value={option.key}>{option.label}{option.count}</option>
))}
</optgroup>
<option value="unmarked"> g/kg{UNMARKED_SCALE_COUNT}</option>
</select>
</label>
<p><strong></strong>g/kg/Kg通常表示舵机扭矩等级</p>
</div>
<p className="catalog-result-count">
<strong>{filteredCatalog.length}</strong>
</p>
</div>
{visibleCatalog.length > 0 ? (
<div className="catalog-grid">
{visibleCatalog.map((product) => (
<article className="catalog-card" key={product.id}>
<button
type="button"
className="catalog-image-button"
onClick={() => openCatalogProduct(product)}
aria-label={`查看${product.name}详情`}
>
{product.image ? (
<img src={getCatalogProductThumbnail(product.image)} alt={product.name} loading="lazy" />
) : (
<span className="catalog-placeholder">GAUDELOT</span>
)}
<small>{product.category}</small>
</button>
<div className="catalog-card-body">
<p>{product.code}</p>
<h3>{product.name}</h3>
<dl>
{getFeaturedSpecs(product).map(([label, value]) => (
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
))}
</dl>
<button type="button" onClick={() => openCatalogProduct(product)}>
<span></span>
</button>
<button
className="add-cart-button"
type="button"
onClick={() => addToCart(product)}
aria-label={`${product.name}加入购物车`}
>
<span></span>
</button>
<button
className="spec-sheet-button"
type="button"
onClick={() => requestSpecSheet(product)}
aria-label={`下载${product.name}产品规格书`}
>
<span></span>
</button>
</div>
</article>
))}
</div>
) : (
<div className="catalog-empty">
<strong></strong>
<p></p>
<button type="button" onClick={() => { setCatalogQuery(""); setCatalogCategory("全部产品"); setCatalogScale("all"); }}>
</button>
</div>
)}
{catalogLimit < filteredCatalog.length && (
<div className="load-more-wrap">
<button type="button" onClick={() => setCatalogLimit((current) => current + 24)}>
<span> {visibleCatalog.length} / {filteredCatalog.length}</span>
</button>
</div>
)}
</div>
<p className="eyebrow precision-gear-section-label"><span /> 齿</p>
<div className="precision-gear-catalog" id="precision-gears" aria-label="精密齿轮配件产品卡片">
<div className="precision-gear-catalog-heading">
<div>
<p className="step-kicker"></p>
<h4>齿 · 511 </h4>
</div>
<p></p>
</div>
<div className={`precision-gear-controls ${precisionGearToolbarCompact ? "is-compact" : ""} ${precisionGearFiltersOpen ? "filters-open" : ""}`}>
<input
value={precisionGearQuery}
onChange={(event) => {
setPrecisionGearQuery(event.target.value);
setPrecisionGearLimit(24);
}}
placeholder="搜索齿轮名称、编码、材质或规格"
aria-label="搜索精密齿轮配件"
/>
<button
className="precision-gear-mobile-filter-toggle"
type="button"
aria-expanded={precisionGearFiltersOpen}
onClick={() => setPrecisionGearFiltersOpen((open) => !open)}
>
{precisionGearFiltersOpen ? "收起" : "筛选"}
</button>
<div className="precision-gear-pills" aria-label="精密齿轮子类筛选">
<button
type="button"
className={precisionGearCategory === "全部子类" ? "active" : ""}
onClick={() => {
setPrecisionGearCategory("全部子类");
setPrecisionGearLimit(24);
}}
> · 511</button>
{PRECISION_GEAR_SUBCATEGORIES.map(([name, count]) => (
<button
type="button"
key={name}
className={precisionGearCategory === name ? "active" : ""}
onClick={() => {
setPrecisionGearCategory(name);
setPrecisionGearLimit(24);
}}
>{name} · {count}</button>
))}
</div>
<p className="precision-gear-result"> <strong>{filteredPrecisionGears.length}</strong> </p>
</div>
{visiblePrecisionGears.length > 0 ? (
<div className="precision-gear-grid">
{visiblePrecisionGears.map((product) => (
<article className="precision-gear-card" key={product.id}>
<button
type="button"
className="precision-gear-image"
onClick={() => openCatalogProduct(product)}
aria-label={`查看${product.name}大图`}
>
<img src={getCatalogProductThumbnail(product.image)} alt={product.name} loading="lazy" />
<span></span>
</button>
<div className="precision-gear-card-body">
<p>{product.subCategory}</p>
<h5>{product.name}</h5>
<code>{product.code}</code>
<dl>
{getPrecisionGearCardSpecs(product).map(([label, value]) => (
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
))}
</dl>
<button
className="add-cart-button"
type="button"
onClick={() => addToCart(product, "精密齿轮配件")}
aria-label={`${product.name}加入购物车`}
>
<span></span>
</button>
<button
className="spec-sheet-button"
type="button"
onClick={() => requestSpecSheet(product)}
aria-label={`下载${product.name}产品规格书`}
>
<span></span>
</button>
</div>
</article>
))}
</div>
) : (
<div className="catalog-empty precision-gear-empty">
<strong>齿</strong>
<p></p>
<button type="button" onClick={() => { setPrecisionGearQuery(""); setPrecisionGearCategory("全部子类"); setPrecisionGearLimit(24); }}>
</button>
</div>
)}
{precisionGearLimit < filteredPrecisionGears.length && (
<div className="load-more-wrap precision-gear-load-more">
<button type="button" onClick={() => setPrecisionGearLimit((current) => current + 24)}>
齿 <span> {visiblePrecisionGears.length} / {filteredPrecisionGears.length}</span>
</button>
</div>
)}
</div>
</section>
{selectedCatalogProduct && (
<div
className="catalog-modal-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setSelectedCatalogProduct(null);
}}
>
<section
className="catalog-modal"
role="dialog"
aria-modal="true"
aria-labelledby="catalog-modal-title"
>
<button
className="catalog-modal-close"
type="button"
onClick={() => setSelectedCatalogProduct(null)}
aria-label="关闭产品详情"
>×</button>
<div className="catalog-modal-product">
<div className="catalog-modal-visual-card">
<div className="catalog-modal-visual-toolbar">
<div className="catalog-modal-gallery-switch" role="tablist" aria-label="切换产品图片">
<button
type="button"
className={catalogVisualMode === "main" ? "active" : ""}
onClick={() => setCatalogVisualMode("main")}
role="tab"
aria-selected={catalogVisualMode === "main"}
></button>
<button
type="button"
className={catalogVisualMode === "size" ? "active" : ""}
onClick={() => setCatalogVisualMode("size")}
disabled={!selectedCatalogDimensionImage}
role="tab"
aria-selected={catalogVisualMode === "size"}
></button>
</div>
<div className="catalog-modal-inline-info">
<span>{selectedCatalogProduct.category}</span>
<h2 id="catalog-modal-title">{selectedCatalogProduct.name}</h2>
<code>{selectedCatalogProduct.code}</code>
<small>
{selectedCatalogProduct.page > 0
? `官方产品目录第 ${selectedCatalogProduct.page}`
: selectedCatalogProduct.category === "舵盘配件"
? "商城产品资料 · 舵盘配件"
: "商城产品资料 · 精密齿轮配件"}
</small>
</div>
</div>
<div className="catalog-modal-image">
{catalogVisualMode === "size" && selectedCatalogDimensionImage ? (
<img src={selectedCatalogDimensionImage} alt={`${selectedCatalogProduct.name} 产品尺寸`} />
) : selectedCatalogProduct.image ? (
<img src={selectedCatalogProduct.image} alt={selectedCatalogProduct.name} />
) : (
<span className="catalog-placeholder">GAUDELOT</span>
)}
</div>
</div>
</div>
<div className="catalog-spec-table">
{Object.entries(selectedCatalogProduct.specs).map(([label, value]) => (
<div key={label}><span>{label}</span><strong>{value}</strong></div>
))}
</div>
<div className="catalog-modal-actions">
<button
className="panel-primary"
type="button"
onClick={() => {
setSelectedCatalogProduct(null);
setStage(1);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
AI继续确认需求 <span></span>
</button>
<button
className="add-cart-button modal-cart-button"
type="button"
onClick={() => addToCart(selectedCatalogProduct)}
aria-label={`${selectedCatalogProduct.name}加入购物车`}
>
<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>
</div>
)}
<section className="how-section" id="how">
<div className="how-copy">
<p className="eyebrow light"><span /> </p>
<h2><br /></h2>
<p>AI负责需求采集线</p>
<button className="primary-action light-button" type="button" onClick={() => { setStage(1); window.scrollTo({ top: 0, behavior: "smooth" }); }}>
<span></span>
</button>
</div>
<div className="flow-list">
<div><b>01</b><span><strong></strong><small></small></span></div>
<div><b>02</b><span><strong>AI生成候选方案</strong><small></small></span></div>
<div><b>03</b><span><strong></strong><small>线</small></span></div>
<div><b>04</b><span><strong></strong><small></small></span></div>
</div>
</section>
<footer>
<div className="brand footer-brand">
<span className="brand-wordmark"><strong>GAUDELOT</strong><small></small></span>
</div>
<p>AI选型结果仅供初步筛选</p>
<span> · V1</span>
</footer>
</main>
);
}