Expand selector to full servo catalog

This commit is contained in:
Jacky Ser
2026-08-10 16:21:49 +08:00
parent 09a99d5e48
commit 596493a686
2 changed files with 297 additions and 323 deletions

View File

@@ -137,17 +137,25 @@ a { color: inherit; text-decoration: none; }
.back-button, .restart-button { padding: 11px 2px; border: 0; color: #798599; background: transparent; cursor: pointer; font-size: 12px; } .back-button, .restart-button { padding: 11px 2px; border: 0; color: #798599; background: transparent; cursor: pointer; font-size: 12px; }
.next-button { min-width: 150px; padding: 13px 20px; } .next-button { min-width: 150px; padding: 13px 20px; }
.next-button:disabled { cursor: not-allowed; opacity: .35; transform: none; box-shadow: none; } .next-button:disabled { cursor: not-allowed; opacity: .35; transform: none; box-shadow: none; }
.field-stack { padding: 18px 18px 15px; border-radius: 12px; background: #f6f8fc; } .field-stack { margin-top: 13px; padding: 15px 16px 13px; border-radius: 12px; background: #f6f8fc; }
.field-label { display: flex; justify-content: space-between; color: #657187; font-size: 12px; } .field-label { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #657187; font-size: 12px; }
.field-label strong { color: var(--blue); font-size: 15px; } .field-label strong { color: var(--blue); font-size: 15px; }
.range-input { width: 100%; margin: 24px 0 6px; accent-color: var(--blue); } .torque-entry { display: flex; align-items: center; gap: 6px; }
.torque-entry input { width: 86px; height: 34px; padding: 0 9px; text-align: right; font-weight: 750; }
.torque-entry strong { font-size: 10px; white-space: nowrap; }
.range-input { width: 100%; margin: 16px 0 5px; accent-color: var(--blue); }
.range-scale { display: flex; justify-content: space-between; color: #9aa3b1; font-size: 9px; } .range-scale { display: flex; justify-content: space-between; color: #9aa3b1; font-size: 9px; }
.torque-presets { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; margin-top: 10px; }
.torque-presets button { min-width: 0; padding: 6px 2px; border: 1px solid #d8e0eb; border-radius: 7px; color: #657187; background: white; cursor: pointer; font-size: 9px; }
.torque-presets button.active { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); font-weight: 750; }
.two-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 15px; } .two-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 15px; }
.compact-fields { margin-top: 0; }
.parameter-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; margin-top: 13px; }
.select-field, .text-field { display: grid; gap: 7px; } .select-field, .text-field { display: grid; gap: 7px; }
.select-field span, .text-field span, .field-title, .sample-form label span { color: #68758a; font-size: 11px; font-weight: 650; } .select-field span, .text-field span, .field-title, .sample-form label span { color: #68758a; font-size: 11px; font-weight: 650; }
select, input, textarea { outline: none; border: 1px solid #dbe2ec; border-radius: 9px; color: var(--ink); background: white; } select, input, textarea { outline: none; border: 1px solid #dbe2ec; border-radius: 9px; color: var(--ink); background: white; }
select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(24,94,232,.1); } select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(24,94,232,.1); }
.select-field select { height: 46px; padding: 0 12px; } .select-field select, .parameter-input { width: 100%; height: 43px; padding: 0 12px; }
.text-field { margin-top: 13px; } .text-field { margin-top: 13px; }
.text-field input { height: 44px; padding: 0 13px; } .text-field input { height: 44px; padding: 0 13px; }
.field-group { margin: 17px 0 13px; } .field-group { margin: 17px 0 13px; }
@@ -159,7 +167,7 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
.result-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; } .result-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; }
.best-match { display: grid; grid-template-columns: 180px 1fr; border: 1px solid #cfdaf0; border-radius: 15px; overflow: hidden; box-shadow: 0 8px 24px rgba(38,70,125,.08); } .best-match { display: grid; grid-template-columns: 180px 1fr; border: 1px solid #cfdaf0; border-radius: 15px; overflow: hidden; box-shadow: 0 8px 24px rgba(38,70,125,.08); }
.match-image-wrap { position: relative; min-height: 270px; background: #eff4fb; } .match-image-wrap { position: relative; min-height: 270px; background: #eff4fb; }
.match-image-wrap img { width: 100%; height: 100%; object-fit: cover; } .match-image-wrap img { width: 100%; height: 100%; object-fit: contain; mix-blend-mode: multiply; }
.match-badge { position: absolute; left: 12px; top: 12px; padding: 6px 9px; border-radius: 7px; color: white; background: var(--orange); font-size: 9px; font-weight: 800; } .match-badge { position: absolute; left: 12px; top: 12px; padding: 6px 9px; border-radius: 7px; color: white; background: var(--orange); font-size: 9px; font-weight: 800; }
.match-content { position: relative; padding: 19px 19px 16px; } .match-content { position: relative; padding: 19px 19px 16px; }
.match-score { position: absolute; right: 16px; top: 15px; display: grid; text-align: right; color: #7c889a; font-size: 9px; } .match-score { position: absolute; right: 16px; top: 15px; display: grid; text-align: right; color: #7c889a; font-size: 9px; }
@@ -176,6 +184,10 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
.price-line span { color: #7c8798; font-size: 9px; } .price-line span { color: #7c8798; font-size: 9px; }
.price-line strong { color: #e55726; font-size: 20px; } .price-line strong { color: #e55726; font-size: 20px; }
.price-line small { color: #9aa4b3; font-size: 8px; } .price-line small { color: #9aa4b3; font-size: 8px; }
.source-line { display: flex; align-items: center; gap: 7px; padding-top: 9px; border-top: 1px solid #edf0f4; }
.source-line span { color: #7c8798; font-size: 9px; }
.source-line strong { color: var(--blue); font-size: 10px; }
.source-line small { margin-left: auto; color: #9aa4b3; font-size: 8px; }
.engineer-alert { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 12px; border: 1px solid #f0d29e; border-radius: 9px; background: #fff9ef; } .engineer-alert { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 12px; border: 1px solid #f0d29e; border-radius: 9px; background: #fff9ef; }
.engineer-alert span { flex: 0 0 auto; padding: 4px 7px; border-radius: 5px; color: #9b5a0a; background: #ffe7bc; font-size: 8px; font-weight: 800; } .engineer-alert span { flex: 0 0 auto; padding: 4px 7px; border-radius: 5px; color: #9b5a0a; background: #ffe7bc; font-size: 8px; font-weight: 800; }
.engineer-alert p { margin: 0; color: #815e2f; font-size: 9px; } .engineer-alert p { margin: 0; color: #815e2f; font-size: 9px; }
@@ -439,7 +451,8 @@ footer > span { color: #9aa4b2; font-size: 10px; }
.selector-shell { border-radius: 16px; } .selector-shell { border-radius: 16px; }
.question-panel, .result-panel { padding: 20px 16px; } .question-panel, .result-panel { padding: 20px 16px; }
.welcome-panel { padding: 55px 20px 35px; } .welcome-panel { padding: 55px 20px 35px; }
.choice-grid, .two-fields, .form-grid, .alternative-list { grid-template-columns: 1fr; } .choice-grid, .two-fields, .parameter-grid, .form-grid, .alternative-list { grid-template-columns: 1fr; }
.torque-presets { grid-template-columns: repeat(4, 1fr); }
.best-match { grid-template-columns: 1fr; } .best-match { grid-template-columns: 1fr; }
.match-image-wrap { height: 240px; } .match-image-wrap { height: 240px; }
.capability-strip { grid-template-columns: 1fr 1fr; gap: 24px 0; padding: 28px 16px; } .capability-strip { grid-template-columns: 1fr 1fr; gap: 24px 0; padding: 28px 16px; }

View File

@@ -7,247 +7,20 @@ import {
type CatalogProduct, type CatalogProduct,
} from "./catalog-data"; } from "./catalog-data";
type Gear = "塑料齿" | "半金属齿" | "金属齿";
type Product = {
id: string;
name: string;
subtitle: string;
weight: number;
torque: number;
speed: number;
voltageMin: number;
voltageMax: number;
gear: Gear;
price: number;
size: string;
image: string;
applications: string[];
code: string;
};
type Requirements = { type Requirements = {
application: string; application: string;
category: string;
torque: number; torque: number;
voltage: number; voltage: number;
weightLimit: number; weightLimit: number;
gear: "不限" | Gear; angle: string;
control: string;
gear: string;
annualVolume: string; annualVolume: string;
currentModel: string; currentModel: string;
painPoint: string; painPoint: string;
}; };
const PRODUCTS: Product[] = [
{
id: "17g",
name: "1.7克超微型舵机",
subtitle: "极致轻量,适合微型结构",
weight: 1.7,
torque: 0.09,
speed: 0.12,
voltageMin: 2.7,
voltageMax: 5,
gear: "塑料齿",
price: 30,
size: "13.40 × 6.15 × 16.00 mm",
image: "/products/servo-17g.png",
applications: ["AI毛绒", "智能玩具", "微型机器人", "云台/航模"],
code: "4.A.01.A0060",
},
{
id: "2g",
name: "2克塑料齿舵机",
subtitle: "快速响应的微型执行方案",
weight: 2,
torque: 0.4,
speed: 0.08,
voltageMin: 3.5,
voltageMax: 6,
gear: "塑料齿",
price: 14.4,
size: "16.25 × 8.30 × 16.90 mm",
image: "/products/servo-2g.jpg",
applications: ["AI毛绒", "智能玩具", "微型机器人", "云台/航模"],
code: "4.A.01.A0004",
},
{
id: "37g",
name: "3.7克塑料齿舵机",
subtitle: "轻量玩具与科创教育通用",
weight: 3.7,
torque: 0.8,
speed: 0.09,
voltageMin: 3.5,
voltageMax: 6,
gear: "塑料齿",
price: 14.7,
size: "20.40 × 8.70 × 20.32 mm",
image: "/products/servo-37g.jpg",
applications: ["AI毛绒", "智能玩具", "教育/积木", "云台/航模"],
code: "4.A.01.A0003",
},
{
id: "9p",
name: "9克塑料齿舵机",
subtitle: "成本优先的标准轻载方案",
weight: 9,
torque: 2.1,
speed: 0.11,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 13.1,
size: "23.00 × 12.36 × 28.50 mm",
image: "/products/servo-9g-plastic.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人", "云台/航模"],
code: "4.A.01.A0009",
},
{
id: "9h",
name: "9克半金属齿舵机",
subtitle: "耐久与成本兼顾",
weight: 9,
torque: 3.2,
speed: 0.13,
voltageMin: 4.8,
voltageMax: 6,
gear: "半金属齿",
price: 18,
size: "23.00 × 12.36 × 28.50 mm",
image: "/products/servo-9g-hybrid.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人", "云台/航模"],
code: "4.A.01.B0001",
},
{
id: "9m",
name: "9克全金属齿舵机",
subtitle: "高频使用与冲击负载优选",
weight: 9,
torque: 3.2,
speed: 0.13,
voltageMin: 4.8,
voltageMax: 6,
gear: "金属齿",
price: 28.7,
size: "23.00 × 12.36 × 28.50 mm",
image: "/products/servo-9g-metal.jpg",
applications: ["教育/积木", "微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0026",
},
{
id: "17p",
name: "17克塑料齿舵机",
subtitle: "玩具机构的高性价比选择",
weight: 17,
torque: 5,
speed: 0.1,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 14.9,
size: "28.60 × 13.00 × 31.00 mm",
image: "/products/servo-17g-plastic.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人"],
code: "4.A.01.A0002",
},
{
id: "17m",
name: "17克金属齿舵机",
subtitle: "宽电压、高扭矩、耐冲击",
weight: 17,
torque: 8.8,
speed: 0.18,
voltageMin: 4.8,
voltageMax: 8.4,
gear: "金属齿",
price: 27.8,
size: "28.60 × 13.00 × 31.00 mm",
image: "/products/servo-17g-metal.jpg",
applications: ["智能玩具", "微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0007",
},
{
id: "25p",
name: "25克塑料齿舵机",
subtitle: "中等负载的经济型方案",
weight: 25,
torque: 7.5,
speed: 0.15,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 16.4,
size: "36.00 × 15.20 × 29.50 mm",
image: "/products/servo-25g-plastic.jpg",
applications: ["智能玩具", "教育/积木", "微型机器人"],
code: "4.A.01.A0001",
},
{
id: "25m",
name: "25克金属齿舵机",
subtitle: "中型机器人和高频机构",
weight: 25,
torque: 8.2,
speed: 0.15,
voltageMin: 4.8,
voltageMax: 8.4,
gear: "金属齿",
price: 44.7,
size: "36.00 × 15.20 × 29.50 mm",
image: "/products/servo-25g-metal.jpg",
applications: ["微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0014",
},
{
id: "37p",
name: "37克塑料齿舵机",
subtitle: "标准尺寸、大负载经济型",
weight: 37,
torque: 6.8,
speed: 0.22,
voltageMin: 4.8,
voltageMax: 6,
gear: "塑料齿",
price: 18.4,
size: "40.85 × 20.15 × 39.15 mm",
image: "/products/servo-37g-plastic.jpg",
applications: ["智能玩具", "微型机器人", "工业设备"],
code: "4.A.01.B0015",
},
{
id: "37h",
name: "37克半金属齿舵机",
subtitle: "大扭矩与成本的平衡点",
weight: 37,
torque: 11.2,
speed: 0.26,
voltageMin: 4.8,
voltageMax: 6,
gear: "半金属齿",
price: 32.9,
size: "40.85 × 20.15 × 39.15 mm",
image: "/products/servo-37g-hybrid.jpg",
applications: ["微型机器人", "云台/航模", "工业设备"],
code: "4.A.01.B0005",
},
{
id: "37m",
name: "37克金属齿舵机",
subtitle: "24kgf·cm级强劲输出",
weight: 37,
torque: 24,
speed: 0.22,
voltageMin: 4.8,
voltageMax: 8.4,
gear: "金属齿",
price: 37.6,
size: "40.85 × 20.15 × 39.15 mm",
image: "/products/servo-37g-metal.jpg",
applications: ["机器人/机械臂", "云台/航模", "工业设备"],
code: "4.A.01.B0017",
},
];
const APPLICATIONS = [ const APPLICATIONS = [
{ name: "AI毛绒", note: "头部、耳朵、手臂等轻量动作" }, { name: "AI毛绒", note: "头部、耳朵、手臂等轻量动作" },
{ name: "智能玩具", note: "互动机构、表情和行走结构" }, { name: "智能玩具", note: "互动机构、表情和行走结构" },
@@ -287,11 +60,112 @@ function getFeaturedSpecs(product: CatalogProduct) {
.map((label) => [label, product.specs[label]] as const); .map((label) => [label, product.specs[label]] as const);
} }
const SERVO_CATEGORIES = ["标准舵机", "异形舵机", "全金属舵机", "大扭矩舵机", "积木舵机"];
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 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),
gear: classifyGear(product.specs["齿轮材质"]),
angle: product.specs["可操作角度"] ?? product.specs["操作角度范围"] ?? "",
control,
speed: product.specs["空载速度"] ?? product.specs["转动速度"] ?? "",
size: product.specs["产品尺寸"] ?? product.specs["外形尺寸"] ?? "",
};
});
const DEFAULT_REQUIREMENTS: Requirements = { const DEFAULT_REQUIREMENTS: Requirements = {
application: "", application: "",
category: "全部舵机",
torque: 2, torque: 2,
voltage: 5, voltage: 5,
weightLimit: 40, weightLimit: 0,
angle: "不限",
control: "不限",
gear: "不限", gear: "不限",
annualVolume: "500050000件", annualVolume: "500050000件",
currentModel: "", currentModel: "",
@@ -299,58 +173,69 @@ const DEFAULT_REQUIREMENTS: Requirements = {
}; };
function getMatches(requirements: Requirements) { function getMatches(requirements: Requirements) {
return PRODUCTS.map((product) => { return SELECTOR_PRODUCTS.map((product) => {
let score = 34; let score = 20;
const reasons: string[] = []; const reasons: string[] = [];
const categoryOk =
requirements.category === "全部舵机" || requirements.category === product.category;
const torqueOk = product.torque >= requirements.torque;
const voltageOk =
requirements.voltage >= product.voltageMin && requirements.voltage <= product.voltageMax;
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 (product.applications.includes(requirements.application)) { if (categoryOk) score += requirements.category === "全部舵机" ? 6 : 16;
score += 18; else score -= 120;
if (APPLICATION_CATEGORY_MAP[requirements.application]?.includes(product.category)) {
score += 12;
reasons.push(`适合${requirements.application}场景`); reasons.push(`适合${requirements.application}场景`);
} }
if (product.torque >= requirements.torque) { if (torqueOk) {
score += 25; 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); const margin = Math.round((product.torque / requirements.torque - 1) * 100);
reasons.push(`扭矩满足,约有${Math.max(0, margin)}%余量`); reasons.push(`扭矩满足,约有${Math.max(0, margin)}%余量`);
} else { } else {
score -= 70; score -= 120;
} }
if ( if (voltageOk) {
requirements.voltage >= product.voltageMin && score += 22;
requirements.voltage <= product.voltageMax
) {
score += 14;
reasons.push(`${requirements.voltage}V供电兼容`); reasons.push(`${requirements.voltage}V供电兼容`);
} else { } else {
score -= 35; score -= 90;
} }
if (product.weight <= requirements.weightLimit) { if (requirements.weightLimit > 0) {
if (product.weight === null) score -= 6;
else if (weightOk) {
score += 10; score += 10;
reasons.push(`重量不超过${requirements.weightLimit}g`); reasons.push(`重量${product.weight}g,符合上限`);
} else { } else score -= 45;
score -= 45;
} }
if (requirements.gear === "不限" || requirements.gear === product.gear) { if (requirements.gear !== "不限") score += gearOk ? 10 : -40;
score += requirements.gear === "不限" ? 4 : 12; if (requirements.angle !== "不限") score += angleOk ? 10 : -40;
if (requirements.gear !== "不限") reasons.push(`${product.gear}符合偏好`); if (requirements.control !== "不限") score += controlOk ? 12 : -50;
} else if (requirements.gear === "金属齿" && product.gear !== "金属齿") {
score -= 22;
}
if (requirements.painPoint === "成本" && product.gear === "塑料齿") score += 8; if (requirements.painPoint === "成本" && product.gear === "塑料齿") score += 8;
if (requirements.painPoint === "耐久" && product.gear === "金属齿") score += 8; if (requirements.painPoint === "耐久" && product.gear === "金属齿") score += 8;
if (requirements.painPoint === "体积" && product.weight <= 9) score += 8; if (requirements.painPoint === "体积" && product.weight !== null && product.weight <= 9) score += 8;
return { return {
product, product,
score, score,
confidence: Math.max(52, Math.min(96, score)), coreCompatible,
confidence: Math.max(48, Math.min(97, Math.round(score))),
reasons: reasons.slice(0, 3), reasons: reasons.slice(0, 3),
}; };
}).sort((a, b) => b.score - a.score); }).sort((a, b) => Number(b.coreCompatible) - Number(a.coreCompatible) || b.score - a.score);
} }
function Metric({ label, value }: { label: string; value: string }) { function Metric({ label, value }: { label: string; value: string }) {
@@ -376,6 +261,7 @@ export default function Home() {
const matches = useMemo(() => getMatches(requirements), [requirements]); const matches = useMemo(() => getMatches(requirements), [requirements]);
const topMatches = matches.slice(0, 3); const topMatches = matches.slice(0, 3);
const top = topMatches[0]; const top = topMatches[0];
const compatibleCount = matches.filter((match) => match.coreCompatible).length;
const filteredCatalog = useMemo(() => { const filteredCatalog = useMemo(() => {
const query = catalogQuery.trim().toLowerCase(); const query = catalogQuery.trim().toLowerCase();
return CATALOG_PRODUCTS.filter((product) => { return CATALOG_PRODUCTS.filter((product) => {
@@ -397,7 +283,7 @@ export default function Home() {
}, [catalogCategory, catalogQuery]); }, [catalogCategory, catalogQuery]);
const visibleCatalog = filteredCatalog.slice(0, catalogLimit); const visibleCatalog = filteredCatalog.slice(0, catalogLimit);
const needsEngineer = const needsEngineer =
top.score < 60 || !top?.coreCompatible ||
requirements.annualVolume === "50000件以上" || requirements.annualVolume === "50000件以上" ||
requirements.application === "工业设备"; requirements.application === "工业设备";
@@ -449,7 +335,7 @@ export default function Home() {
<em></em> <em></em>
</h1> </h1>
<p className="hero-lead"> <p className="hero-lead">
AI从高徳乐标准产品中生成候选方案 AI从高徳乐完整舵机目录中生成候选方案
</p> </p>
<div className="hero-actions"> <div className="hero-actions">
@@ -521,50 +407,134 @@ export default function Home() {
{stage === 2 && ( {stage === 2 && (
<div className="question-step"> <div className="question-step">
<p className="step-kicker"> · </p> <p className="step-kicker"> · </p>
<h2></h2> <h2></h2>
<p className="step-help"></p> <p className="step-help">2430.09920 kgf·cm0.830V</p>
<div className="field-stack"> <div className="two-fields compact-fields">
<label className="field-label" htmlFor="torque"> <label className="select-field">
<strong>{requirements.torque} kgf·cm</strong> <span></span>
<select
value={requirements.category}
onChange={(event) => update("category", event.target.value)}
>
<option value="全部舵机">243</option>
{SERVO_CATEGORIES.map((category) => (
<option key={category} value={category}>
{category}{CATALOG_COUNTS[category]}
</option>
))}
</select>
</label> </label>
<label className="select-field">
<span>0.830V</span>
<input
className="parameter-input"
type="number"
min="0.8"
max="30"
step="0.1"
list="voltage-values"
value={requirements.voltage}
onChange={(event) => update("voltage", Number(event.target.value))}
/>
<datalist id="voltage-values">
{[1.2, 2.7, 3.7, 4.2, 5, 6, 7.4, 8.4, 9, 12, 16.8, 24, 28, 30].map((voltage) => (
<option key={voltage} value={voltage} />
))}
</datalist>
</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 <input
id="torque" id="torque"
className="range-input" className="range-input"
type="range" type="range"
min="0.1" min="0"
max="24" max="100"
step="0.1" step="0.1"
value={requirements.torque} value={torqueToSlider(requirements.torque)}
onChange={(event) => update("torque", Number(event.target.value))} onChange={(event) => update("torque", sliderToTorque(Number(event.target.value)))}
/> />
<div className="range-scale"><span>0.1</span><span>5</span><span>10</span><span>15</span><span>24</span></div> <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="two-fields"> </div>
<label className="select-field"> <div className="parameter-grid">
<span></span>
<select
value={requirements.voltage}
onChange={(event) => update("voltage", Number(event.target.value))}
>
<option value={3.7}>3.7V</option>
<option value={4.2}>4.2V</option>
<option value={5}>5.0V</option>
<option value={6}>6.0V</option>
<option value={7.4}>7.4V</option>
<option value={8.4}>8.4V</option>
</select>
</label>
<label className="select-field"> <label className="select-field">
<span></span> <span></span>
<select <select
value={requirements.weightLimit} value={requirements.weightLimit}
onChange={(event) => update("weightLimit", Number(event.target.value))} onChange={(event) => update("weightLimit", Number(event.target.value))}
> >
<option value={2}>2g以内</option> <option value={0}></option>
<option value={4}>4g以内</option> {[2, 5, 10, 20, 40, 100, 250, 500, 1000, 2000].map((weight) => (
<option value={10}>10g以内</option> <option key={weight} value={weight}>{weight}g以内</option>
<option value={20}>20g以内</option> ))}
<option value={40}>40g以内</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> </select>
</label> </label>
</div> </div>
@@ -575,20 +545,7 @@ export default function Home() {
<div className="question-step"> <div className="question-step">
<p className="step-kicker"> · </p> <p className="step-kicker"> · </p>
<h2></h2> <h2></h2>
<p className="step-help"></p> <p className="step-help"></p>
<div className="field-group">
<span className="field-title">齿</span>
<div className="pill-row">
{(["不限", "塑料齿", "半金属齿", "金属齿"] as const).map((gear) => (
<button
type="button"
key={gear}
className={requirements.gear === gear ? "active" : ""}
onClick={() => update("gear", gear)}
>{gear}</button>
))}
</div>
</div>
<div className="two-fields"> <div className="two-fields">
<label className="select-field"> <label className="select-field">
<span></span> <span></span>
@@ -647,7 +604,7 @@ export default function Home() {
<div className="result-heading"> <div className="result-heading">
<div> <div>
<p className="step-kicker">AI </p> <p className="step-kicker">AI </p>
<h2> {topMatches.length} </h2> <h2>{compatibleCount > 0 ? `找到 ${compatibleCount} 个匹配型号` : "暂无完全匹配型号"}</h2>
</div> </div>
<button type="button" className="restart-button" onClick={reset}></button> <button type="button" className="restart-button" onClick={reset}></button>
</div> </div>
@@ -655,26 +612,28 @@ export default function Home() {
<div className="best-match"> <div className="best-match">
<div className="match-image-wrap"> <div className="match-image-wrap">
<img src={top.product.image} alt={top.product.name} /> <img src={top.product.image} alt={top.product.name} />
<span className="match-badge"></span> <span className="match-badge">{top.coreCompatible ? "首选推荐" : "待复核候选"}</span>
</div> </div>
<div className="match-content"> <div className="match-content">
<div className="match-score"><span></span><strong>{top.confidence}%</strong></div> <div className="match-score"><span></span><strong>{top.confidence}%</strong></div>
<p className="product-code">{top.product.code}</p> <p className="product-code">{top.product.code}</p>
<h3>{top.product.name}</h3> <h3>{top.product.name}</h3>
<p className="product-subtitle">{top.product.subtitle}</p> <p className="product-subtitle">{top.product.category} · {top.product.size || "尺寸待复核"}</p>
<div className="metric-grid"> <div className="metric-grid">
<Metric label="堵转扭矩" value={`${top.product.torque} kgf·cm`} /> <Metric label="堵转扭矩" value={`${formatTorque(top.product.torque)} kgf·cm`} />
<Metric label="空载速度" value={`${top.product.speed}s/60°`} />
<Metric label="工作电压" value={`${top.product.voltageMin}${top.product.voltageMax}V`} /> <Metric label="工作电压" value={`${top.product.voltageMin}${top.product.voltageMax}V`} />
<Metric label="空载速度" value={top.product.speed || "目录未标注"} />
<Metric label="操作角度" value={top.product.angle || "目录未标注"} />
<Metric label="控制 / 通讯" value={top.product.control || "目录未标注"} />
<Metric label="齿轮材质" value={top.product.gear} /> <Metric label="齿轮材质" value={top.product.gear} />
</div> </div>
<ul className="reason-list"> <ul className="reason-list">
{top.reasons.map((reason) => <li key={reason}> {reason}</li>)} {top.reasons.map((reason) => <li key={reason}> {reason}</li>)}
</ul> </ul>
<div className="price-line"> <div className="source-line">
<span></span> <span></span>
<strong>¥{top.product.price.toFixed(1)}</strong> <strong>{top.product.category}</strong>
<small>/ · </small> <small></small>
</div> </div>
</div> </div>
</div> </div>
@@ -682,7 +641,9 @@ export default function Home() {
{needsEngineer && ( {needsEngineer && (
<div className="engineer-alert"> <div className="engineer-alert">
<span></span> <span></span>
<p></p> <p>{compatibleCount === 0
? "当前条件没有完全匹配的目录型号,建议调整电压、扭矩余量或发起定制评估。"
: "你的项目包含高用量、工业场景或边界参数,系统已标记为优先人工复核。"}</p>
</div> </div>
)} )}
@@ -693,7 +654,7 @@ export default function Home() {
<div> <div>
<span>{match.confidence}% </span> <span>{match.confidence}% </span>
<h4>{match.product.name}</h4> <h4>{match.product.name}</h4>
<p>{match.product.torque} kgf·cm · {match.product.gear} · ¥{match.product.price.toFixed(1)}</p> <p>{formatTorque(match.product.torque)} kgf·cm · {match.product.category} · {match.product.gear}</p>
</div> </div>
</div> </div>
))} ))}