Files
gdm-Servo-model/app/page.tsx
2026-08-11 16:56:38 +08:00

1313 lines
58 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, 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>
);
}
export default function Home() {
const [stage, setStage] = useState(0);
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 [precisionGearQuery, setPrecisionGearQuery] = useState("");
const [precisionGearCategory, setPrecisionGearCategory] = useState("全部子类");
const [precisionGearLimit, setPrecisionGearLimit] = useState(24);
const [selectedCatalogProduct, setSelectedCatalogProduct] = useState<CatalogProduct | null>(null);
const [catalogVisualMode, setCatalogVisualMode] = useState<CatalogVisualMode>("main");
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;
function openCatalogProduct(product: CatalogProduct) {
setSelectedCatalogProduct(product);
setCatalogVisualMode("main");
}
function startSelection() {
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>
<header className="site-header">
<a className="brand" href="#top" aria-label="高徳乐 AI选型首页">
<span className="brand-mark">G</span>
<span>
<strong>GAUDELOT</strong>
<small> · </small>
</span>
</a>
<nav aria-label="主导航">
<a href="#selector">AI选型</a>
<a href="#products"></a>
<a href="#precision-gears">齿</a>
<a href="#how"></a>
</nav>
<button className="header-cta" type="button" onClick={startSelection}>
<span></span>
</button>
</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>
</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-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">
<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>
<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>
<span className="catalog-zoom-hint"></span>
</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>
</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>
)}
<aside className="precision-gear-panel" id="precision-gears" aria-labelledby="precision-gears-title">
<div className="precision-gear-copy">
<p className="eyebrow"><span /> 齿</p>
<h3 id="precision-gears-title">齿</h3>
<p> 511 11 齿</p>
</div>
<div className="precision-gear-overview">
<div className="precision-gear-stat"><strong>511</strong><span></span></div>
<div className="precision-gear-stat"><strong>11</strong><span></span></div>
<div className="precision-gear-subcats" aria-label="精密齿轮配件子类">
{PRECISION_GEAR_SUBCATEGORIES.map(([name, count]) => (
<span key={name}>{name} <b>{count}</b></span>
))}
</div>
</div>
</aside>
<div className="precision-gear-catalog" 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">
<input
value={precisionGearQuery}
onChange={(event) => {
setPrecisionGearQuery(event.target.value);
setPrecisionGearLimit(24);
}}
placeholder="搜索齿轮名称、编码、材质或规格"
aria-label="搜索精密齿轮配件"
/>
<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>
</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={() => setSelectedCatalogProduct(null)}
>
<section
className="catalog-modal"
role="dialog"
aria-modal="true"
aria-labelledby="catalog-modal-title"
onMouseDown={(event) => event.stopPropagation()}
>
<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>
<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-mark">G</span>
<span><strong>GAUDELOT</strong><small> · </small></span>
</div>
<p>AI选型结果仅供初步筛选</p>
<span> · V1</span>
</footer>
</main>
);
}