Files
gdm-Servo-model/app/page.tsx
2026-08-12 18:30:50 +08:00

1658 lines
74 KiB
TypeScript
Raw 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 } 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 === "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 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: "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.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 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 [savingProductKey, setSavingProductKey] = useState("");
const [saveProductMessage, setSaveProductMessage] = 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;
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);
};
}, []);
function openCatalogProduct(product: CatalogProduct) {
setSelectedCatalogProduct(product);
setCatalogVisualMode("main");
}
async function saveProductCard(request: SaveProductRequest) {
if (savingProductKey) return;
const saveKey = `${request.product.id}-${request.badge ?? "product"}`;
setSavingProductKey(saveKey);
setSaveProductMessage("正在生成产品图片…");
try {
const blob = await renderProductCardPng(request);
const safeName = `${request.product.code}-${request.product.name}`
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, "-")
.slice(0, 90);
const fileName = `${safeName || request.product.id}.png`;
const file = new File([blob], fileName, { type: "image/png" });
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 0 && window.innerWidth <= 900);
const canShareFile = typeof navigator.share === "function" &&
(!navigator.canShare || navigator.canShare({ files: [file] }));
if (isMobile && canShareFile) {
try {
await navigator.share({
title: `${request.product.name} 产品资料`,
text: `高徳乐${request.product.category} · ${request.product.code}`,
files: [file],
});
setSaveProductMessage("已打开系统分享面板");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setSaveProductMessage("已取消保存");
} else {
throw error;
}
}
} else {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setSaveProductMessage("PNG图片已下载");
}
} catch (error) {
console.error("保存产品参数图片失败", error);
setSaveProductMessage("图片生成失败,请稍后重试");
} finally {
setSavingProductKey("");
window.setTimeout(() => setSaveProductMessage(""), 2800);
}
}
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>
{saveProductMessage && (
<div className="product-save-toast" role="status" aria-live="polite">
{saveProductMessage}
</div>
)}
<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="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.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={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={`product-save-button ${savingProductKey.startsWith(`${match.product.id}-`) ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product: match.product,
badge: index === 0 ? (match.coreCompatible ? "首选推荐" : "待复核候选") : `${match.confidence}% 匹配`,
subtitle: `${match.product.category} · ${match.product.size || "尺寸待复核"}`,
extraSpecs: [["AI匹配度", `${match.confidence}%`]],
})}
aria-label={`保存${match.product.name}产品参数图片`}
>
{savingProductKey.startsWith(`${match.product.id}-`) ? "正在生成…" : "保存产品参数图片"} <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={() => window.print()}> / </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={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={`product-save-button ${savingProductKey === `${product.id}-${product.category}` ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product,
badge: product.category,
subtitle: "完整产品参数",
})}
aria-label={`保存${product.name}产品参数图片`}
>
{savingProductKey === `${product.id}-${product.category}` ? "正在生成…" : "保存产品参数图片"} <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={`product-save-button ${savingProductKey === `${product.id}-${product.subCategory}` ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product,
badge: "精密齿轮配件",
subtitle: product.subCategory,
extraSpecs: [["产品子类", product.subCategory]],
})}
aria-label={`保存${product.name}产品参数图片`}
>
{savingProductKey === `${product.id}-${product.subCategory}` ? "正在生成…" : "保存产品参数图片"} <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={`product-save-button modal-save-button ${savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "is-saving" : ""}`}
type="button"
onClick={() => saveProductCard({
product: selectedCatalogProduct,
badge: selectedCatalogProduct.category,
subtitle: "完整产品参数",
})}
aria-label={`保存${selectedCatalogProduct.name}产品参数图片`}
>
{savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "正在生成…" : "保存产品参数图片"} <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>
);
}