2135 lines
93 KiB
TypeScript
2135 lines
93 KiB
TypeScript
"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 } 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, 3)
|
||
.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 === "270–330°") return firstAngle >= 270 && firstAngle < 360;
|
||
return productAngle.includes("多圈") || firstAngle >= 360;
|
||
}
|
||
|
||
function formatTorque(value: number) {
|
||
if (value < 1) return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||
if (value < 100) return value.toFixed(1).replace(/\.0$/, "");
|
||
return Math.round(value).toString();
|
||
}
|
||
|
||
function torqueToSlider(value: number) {
|
||
return ((Math.log(value) - Math.log(TORQUE_MIN)) / (Math.log(TORQUE_MAX) - Math.log(TORQUE_MIN))) * 100;
|
||
}
|
||
|
||
function sliderToTorque(position: number) {
|
||
const value = Math.exp(
|
||
Math.log(TORQUE_MIN) + (position / 100) * (Math.log(TORQUE_MAX) - Math.log(TORQUE_MIN)),
|
||
);
|
||
if (value < 1) return Number(value.toFixed(2));
|
||
if (value < 100) return Number(value.toFixed(1));
|
||
return Math.round(value);
|
||
}
|
||
|
||
const SELECTOR_PRODUCTS = CATALOG_PRODUCTS.filter((product) =>
|
||
SERVO_CATEGORIES.includes(product.category),
|
||
).map((product) => {
|
||
const voltageValues = parseNumbers(product.specs["电压范围"]);
|
||
const ratedVoltage = product.ratedVoltage ?? 0;
|
||
const control = [
|
||
product.specs["信号类型"],
|
||
product.specs["通讯方式"],
|
||
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["操作角度范围"] ?? "",
|
||
control,
|
||
speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "",
|
||
size: product.specs["产品尺寸"] ?? product.specs["外形尺寸"] ?? "",
|
||
};
|
||
});
|
||
|
||
const RATED_VOLTAGES = [...new Set(
|
||
SELECTOR_PRODUCTS
|
||
.map((product) => product.ratedVoltage)
|
||
.filter((voltage): voltage is number => voltage !== null),
|
||
)].sort((a, b) => a - b);
|
||
|
||
const DEFAULT_REQUIREMENTS: Requirements = {
|
||
application: "",
|
||
category: "全部舵机",
|
||
torque: 2,
|
||
voltage: 5,
|
||
weightLimit: 0,
|
||
angle: "不限",
|
||
control: "不限",
|
||
gear: "不限",
|
||
annualVolume: "5000–50000件",
|
||
firstOrderQuantity: "",
|
||
currentModel: "",
|
||
otherRequirements: "",
|
||
painPoint: "交期",
|
||
};
|
||
|
||
function getMatches(requirements: Requirements) {
|
||
return SELECTOR_PRODUCTS.map((product) => {
|
||
let score = 20;
|
||
const reasons: string[] = [];
|
||
const categoryOk = requirements.category === "全部舵机" ||
|
||
requirements.category === product.category ||
|
||
(requirements.category === SILENT_SERVO_CATEGORY && product.isSilent);
|
||
const torqueOk = product.torque >= requirements.torque;
|
||
const voltageOk = requirements.voltage === product.ratedVoltage;
|
||
const weightOk =
|
||
requirements.weightLimit === 0 || product.weight === null || product.weight <= requirements.weightLimit;
|
||
const gearOk = requirements.gear === "不限" || requirements.gear === product.gear;
|
||
const angleOk = angleMatches(requirements.angle, product.angle);
|
||
const controlOk = controlMatches(requirements.control, product.control);
|
||
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);
|
||
}
|
||
|
||
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 换算
|
||
|
||
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 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: 27 },
|
||
{ 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.1, 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 downloadCartWorkbook(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 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 [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 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
downloadCartWorkbook(file, filename);
|
||
setCartMessage("产品清单已下载");
|
||
} catch (error) {
|
||
console.error("生成购物车清单失败", error);
|
||
setCartMessage("产品清单生成失败,请稍后重试");
|
||
} finally {
|
||
setExportingCart(false);
|
||
window.setTimeout(() => setCartMessage(""), 2400);
|
||
}
|
||
}
|
||
|
||
function exportCartPdf() {
|
||
if (cartItems.length === 0) return;
|
||
const previousTitle = document.title;
|
||
document.body.classList.add("printing-cart");
|
||
document.title = getCartExportFilename().replace(/\.xlsx$/, ".pdf");
|
||
setCartOpen(false);
|
||
let fallbackCleanupTimer = 0;
|
||
const cleanupPrintMode = () => {
|
||
window.clearTimeout(fallbackCleanupTimer);
|
||
window.removeEventListener("afterprint", cleanupPrintMode);
|
||
document.body.classList.remove("printing-cart");
|
||
document.title = previousTitle;
|
||
};
|
||
window.addEventListener("afterprint", cleanupPrintMode);
|
||
fallbackCleanupTimer = window.setTimeout(cleanupPrintMode, 30000);
|
||
window.setTimeout(() => {
|
||
window.print();
|
||
}, 80);
|
||
}
|
||
|
||
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={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}
|
||
aria-label="导出购物车 PDF"
|
||
>
|
||
导出 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>{getCartExportFilename().replace(/\.xlsx$/, ".pdf")}</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>280</b> 个产品型号</span>
|
||
<span><b>0.09–920</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">已覆盖243款舵机:0.09–920 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>270–330°</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>500–5000件</option>
|
||
<option>5000–50000件</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={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.control || "目录未标注"} />
|
||
<Metric label="齿轮材质" value={match.product.gear} />
|
||
</div>
|
||
<ul className="reason-list">
|
||
{match.reasons.slice(0, 2).map((reason) => <li key={reason}>✓ {reason}</li>)}
|
||
</ul>
|
||
<div className="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>
|
||
</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>1–2只</option><option>3–5只</option><option>6–10只</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={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>
|
||
</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={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>
|
||
</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>
|
||
<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>
|
||
);
|
||
}
|