Add cart exports and sample request workflow
This commit is contained in:
643
app/page.tsx
643
app/page.tsx
@@ -350,6 +350,216 @@ function Metric({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type CartItem = {
|
||||
product: CatalogProduct;
|
||||
quantity: number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const CART_STORAGE_KEY = "gaudelo-product-cart";
|
||||
|
||||
type CartExportImage = {
|
||||
base64: string;
|
||||
extension: "jpeg";
|
||||
};
|
||||
|
||||
const CART_EXPORT_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
const CART_IMAGE_SIZE_PX = 189; // 约 5 cm,按 96 DPI 换算
|
||||
|
||||
function getCartExportFilename() {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
return `高徳乐产品清单_${date}.xlsx`;
|
||||
}
|
||||
|
||||
function getFullSpecEntries(product: CatalogProduct) {
|
||||
return Object.entries(product.specs).filter(([, value]) => value);
|
||||
}
|
||||
|
||||
function getKeySpecText(product: CatalogProduct) {
|
||||
const operationAngle = product.specs["可操作角度"] ?? product.specs["操作角度范围"];
|
||||
return [
|
||||
...getFeaturedSpecs(product),
|
||||
...(operationAngle ? [["可操作角度", operationAngle] as const] : []),
|
||||
]
|
||||
.map(([label, value]) => `${label}:${value}`)
|
||||
.join("\n") || "暂无主要参数";
|
||||
}
|
||||
|
||||
function getFullSpecText(product: CatalogProduct) {
|
||||
return getFullSpecEntries(product)
|
||||
.map(([label, value]) => `${label}:${value}`)
|
||||
.join("\n") || "暂无详细参数";
|
||||
}
|
||||
|
||||
function compressCartImage(src: string) {
|
||||
return new Promise<CartExportImage | null>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = CART_IMAGE_SIZE_PX;
|
||||
canvas.height = CART_IMAGE_SIZE_PX;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const padding = 10;
|
||||
const scale = Math.min(
|
||||
(canvas.width - padding * 2) / image.naturalWidth,
|
||||
(canvas.height - padding * 2) / image.naturalHeight,
|
||||
);
|
||||
const imageWidth = image.naturalWidth * scale;
|
||||
const imageHeight = image.naturalHeight * scale;
|
||||
context.drawImage(
|
||||
image,
|
||||
(canvas.width - imageWidth) / 2,
|
||||
(canvas.height - imageHeight) / 2,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
);
|
||||
resolve({
|
||||
base64: canvas.toDataURL("image/jpeg", 0.78),
|
||||
extension: "jpeg",
|
||||
});
|
||||
};
|
||||
image.onerror = () => resolve(null);
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function buildCartWorkbook(cartItems: CartItem[]) {
|
||||
const { default: ExcelJS } = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = "高徳乐 AI 舵机选型助手";
|
||||
workbook.created = new Date();
|
||||
|
||||
const summarySheet = workbook.addWorksheet("产品清单");
|
||||
const detailsSheet = workbook.addWorksheet("详细参数");
|
||||
summarySheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
detailsSheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
summarySheet.columns = [
|
||||
{ header: "产品图片", key: "image", width: 27 },
|
||||
{ header: "产品名称", key: "name", width: 34 },
|
||||
{ header: "产品编码", key: "code", width: 24 },
|
||||
{ header: "产品分类", key: "category", width: 18 },
|
||||
{ header: "购买数量", key: "quantity", width: 12 },
|
||||
{ header: "详细参数", key: "detailSpecs", width: 48 },
|
||||
];
|
||||
detailsSheet.columns = [
|
||||
{ header: "产品名称", key: "name", width: 34 },
|
||||
{ header: "产品编码", key: "code", width: 24 },
|
||||
{ header: "产品分类", key: "category", width: 18 },
|
||||
{ header: "购买数量", key: "quantity", width: 12 },
|
||||
{ header: "参数名称", key: "label", width: 22 },
|
||||
{ header: "参数值", key: "value", width: 48 },
|
||||
];
|
||||
|
||||
const headerStyle = {
|
||||
font: { bold: true, color: { argb: "FFFFFFFF" } },
|
||||
fill: { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FF185EE8" } },
|
||||
alignment: { vertical: "middle" as const, horizontal: "center" as const, wrapText: true },
|
||||
};
|
||||
summarySheet.getRow(1).eachCell((cell) => {
|
||||
Object.assign(cell, headerStyle);
|
||||
});
|
||||
detailsSheet.getRow(1).eachCell((cell) => {
|
||||
Object.assign(cell, headerStyle);
|
||||
});
|
||||
summarySheet.getRow(1).height = 26;
|
||||
detailsSheet.getRow(1).height = 26;
|
||||
|
||||
const imageCache = new Map<string, Promise<CartExportImage | null>>();
|
||||
const getImageAsset = (src: string) => {
|
||||
const cached = imageCache.get(src);
|
||||
if (cached) return cached;
|
||||
const pending = compressCartImage(src);
|
||||
imageCache.set(src, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
for (const item of cartItems) {
|
||||
const product = item.product;
|
||||
const specEntries = getFullSpecEntries(product);
|
||||
const summaryRow = summarySheet.addRow({
|
||||
image: product.image ? "" : "暂无图片",
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
category: product.category,
|
||||
quantity: item.quantity,
|
||||
detailSpecs: getFullSpecText(product),
|
||||
});
|
||||
summaryRow.height = Math.max(142, Math.min(240, 24 + specEntries.length * 15));
|
||||
summaryRow.eachCell((cell) => {
|
||||
cell.alignment = { vertical: "middle", wrapText: true };
|
||||
cell.border = {
|
||||
top: { style: "thin", color: { argb: "FFDDE6F3" } },
|
||||
bottom: { style: "thin", color: { argb: "FFDDE6F3" } },
|
||||
left: { style: "thin", color: { argb: "FFDDE6F3" } },
|
||||
right: { style: "thin", color: { argb: "FFDDE6F3" } },
|
||||
};
|
||||
});
|
||||
if (product.image) {
|
||||
const imageAsset = await getImageAsset(product.image);
|
||||
if (imageAsset) {
|
||||
const imageId = workbook.addImage(imageAsset);
|
||||
summarySheet.addImage(imageId, {
|
||||
tl: { col: 0.1, row: summaryRow.number - 1 + 0.08 },
|
||||
ext: { width: CART_IMAGE_SIZE_PX, height: CART_IMAGE_SIZE_PX },
|
||||
});
|
||||
summaryRow.getCell(1).value = null;
|
||||
} else {
|
||||
summaryRow.getCell(1).value = "图片加载失败";
|
||||
}
|
||||
}
|
||||
|
||||
for (const [label, value] of specEntries.length > 0 ? specEntries : [["暂无参数", ""]]) {
|
||||
const detailRow = detailsSheet.addRow({
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
category: product.category,
|
||||
quantity: item.quantity,
|
||||
label,
|
||||
value,
|
||||
});
|
||||
detailRow.height = Math.max(24, Math.min(60, 18 + Math.ceil(String(value).length / 28) * 15));
|
||||
detailRow.eachCell((cell) => {
|
||||
cell.alignment = { vertical: "middle", wrapText: true };
|
||||
cell.border = {
|
||||
top: { style: "thin", color: { argb: "FFE5EBF3" } },
|
||||
bottom: { style: "thin", color: { argb: "FFE5EBF3" } },
|
||||
left: { style: "thin", color: { argb: "FFE5EBF3" } },
|
||||
right: { style: "thin", color: { argb: "FFE5EBF3" } },
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
summarySheet.autoFilter = { from: "A1", to: `F${Math.max(1, summarySheet.rowCount)}` };
|
||||
detailsSheet.autoFilter = { from: "A1", to: `F${Math.max(1, detailsSheet.rowCount)}` };
|
||||
return workbook;
|
||||
}
|
||||
|
||||
function downloadCartWorkbook(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function isMobileDevice() {
|
||||
return /Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
/*
|
||||
type SaveProductRequest = {
|
||||
product: CatalogProduct;
|
||||
badge?: string;
|
||||
@@ -536,6 +746,7 @@ async function renderProductCardPng({ product, badge, subtitle, extraSpecs = []
|
||||
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("PNG生成失败"))), "image/png");
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
export default function Home() {
|
||||
const [stage, setStage] = useState(0);
|
||||
@@ -558,8 +769,22 @@ export default function Home() {
|
||||
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 [cartItems, setCartItems] = useState<CartItem[]>(() => {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const storedCart = window.localStorage.getItem(CART_STORAGE_KEY);
|
||||
const parsedCart = storedCart ? JSON.parse(storedCart) as CartItem[] : [];
|
||||
return Array.isArray(parsedCart) ? parsedCart : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [cartMessage, setCartMessage] = useState("");
|
||||
const [exportingCart, setExportingCart] = useState(false);
|
||||
const [cartSampleFormOpen, setCartSampleFormOpen] = useState(false);
|
||||
const [cartSampleSubmitted, setCartSampleSubmitted] = useState(false);
|
||||
const [cartSampleLeadId, setCartSampleLeadId] = useState("");
|
||||
|
||||
const matches = useMemo(() => getMatches(requirements), [requirements]);
|
||||
const topMatches = matches.slice(0, 3);
|
||||
@@ -613,6 +838,7 @@ export default function Home() {
|
||||
const selectedCatalogDimensionImage = selectedCatalogProduct
|
||||
? getCatalogProductDimensionImage(selectedCatalogProduct)
|
||||
: null;
|
||||
const cartCount = cartItems.reduce((total, item) => total + item.quantity, 0);
|
||||
|
||||
useEffect(() => {
|
||||
function updateCatalogToolbarState() {
|
||||
@@ -642,65 +868,144 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartItems));
|
||||
} catch (error) {
|
||||
console.warn("保存购物车失败", error);
|
||||
}
|
||||
}, [cartItems]);
|
||||
|
||||
function openCatalogProduct(product: CatalogProduct) {
|
||||
setSelectedCatalogProduct(product);
|
||||
setCatalogVisualMode("main");
|
||||
}
|
||||
|
||||
async function saveProductCard(request: SaveProductRequest) {
|
||||
if (savingProductKey) return;
|
||||
const saveKey = `${request.product.id}-${request.badge ?? "product"}`;
|
||||
setSavingProductKey(saveKey);
|
||||
setSaveProductMessage("正在生成产品图片…");
|
||||
function addToCart(product: CatalogProduct, source = product.category) {
|
||||
setCartItems((current) => {
|
||||
const existingItem = current.find((item) => item.product.id === product.id);
|
||||
if (existingItem) {
|
||||
return current.map((item) => item.product.id === product.id
|
||||
? { ...item, quantity: item.quantity + 1, source }
|
||||
: item);
|
||||
}
|
||||
return [...current, { product, quantity: 1, source }];
|
||||
});
|
||||
setCartMessage(`${product.name} 已加入购物车`);
|
||||
setCartOpen(true);
|
||||
window.setTimeout(() => setCartMessage(""), 2200);
|
||||
}
|
||||
|
||||
function addTopMatchesToCart() {
|
||||
if (topMatches.length === 0) return;
|
||||
setCartItems((current) => {
|
||||
const next = [...current];
|
||||
topMatches.forEach((match) => {
|
||||
const existingIndex = next.findIndex((item) => item.product.id === match.product.id);
|
||||
if (existingIndex >= 0) {
|
||||
const existingItem = next[existingIndex];
|
||||
next[existingIndex] = {
|
||||
...existingItem,
|
||||
quantity: existingItem.quantity + 1,
|
||||
source: "AI初选结果",
|
||||
};
|
||||
} else {
|
||||
next.push({ product: match.product, quantity: 1, source: "AI初选结果" });
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setCartMessage(`${topMatches.length} 款 AI 推荐型号已加入购物车`);
|
||||
setCartOpen(true);
|
||||
window.setTimeout(() => setCartMessage(""), 2400);
|
||||
}
|
||||
|
||||
function updateCartQuantity(productId: string, quantity: number) {
|
||||
if (quantity <= 0) {
|
||||
setCartItems((current) => current.filter((item) => item.product.id !== productId));
|
||||
return;
|
||||
}
|
||||
setCartItems((current) => current.map((item) => item.product.id === productId ? { ...item, quantity } : item));
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
setCartItems([]);
|
||||
setCartSampleFormOpen(false);
|
||||
setCartSampleSubmitted(false);
|
||||
}
|
||||
|
||||
function openCartSampleForm() {
|
||||
if (cartItems.length === 0) return;
|
||||
setCartSampleSubmitted(false);
|
||||
setCartSampleFormOpen(true);
|
||||
}
|
||||
|
||||
function submitCartSampleRequest(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const id = `GDL-S-${new Date().toISOString().slice(5, 10).replace("-", "")}-${String(Date.now()).slice(-4)}`;
|
||||
setCartSampleLeadId(id);
|
||||
setCartSampleSubmitted(true);
|
||||
}
|
||||
|
||||
async function saveCartList() {
|
||||
if (cartItems.length === 0 || exportingCart) return;
|
||||
|
||||
setExportingCart(true);
|
||||
setCartMessage("正在生成产品清单…");
|
||||
try {
|
||||
const 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] }));
|
||||
const workbook = await buildCartWorkbook(cartItems);
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const filename = getCartExportFilename();
|
||||
const file = new File([buffer as BlobPart], filename, { type: CART_EXPORT_MIME });
|
||||
|
||||
if (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;
|
||||
if (isMobileDevice() && typeof navigator.share === "function") {
|
||||
const canShareFiles = typeof navigator.canShare !== "function" || navigator.canShare({ files: [file] });
|
||||
if (canShareFiles) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: "高徳乐产品清单",
|
||||
text: "购物车产品清单",
|
||||
files: [file],
|
||||
});
|
||||
setCartMessage("已打开系统分享面板");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
}
|
||||
}
|
||||
} 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图片已下载");
|
||||
}
|
||||
|
||||
downloadCartWorkbook(file, filename);
|
||||
setCartMessage("产品清单已下载");
|
||||
} catch (error) {
|
||||
console.error("保存产品参数图片失败", error);
|
||||
setSaveProductMessage("图片生成失败,请稍后重试");
|
||||
console.error("生成购物车清单失败", error);
|
||||
setCartMessage("产品清单生成失败,请稍后重试");
|
||||
} finally {
|
||||
setSavingProductKey("");
|
||||
window.setTimeout(() => setSaveProductMessage(""), 2800);
|
||||
setExportingCart(false);
|
||||
window.setTimeout(() => setCartMessage(""), 2400);
|
||||
}
|
||||
}
|
||||
|
||||
function exportCartPdf() {
|
||||
if (cartItems.length === 0) return;
|
||||
const previousTitle = document.title;
|
||||
document.body.classList.add("printing-cart");
|
||||
document.title = getCartExportFilename().replace(/\.xlsx$/, ".pdf");
|
||||
setCartOpen(false);
|
||||
let fallbackCleanupTimer = 0;
|
||||
const cleanupPrintMode = () => {
|
||||
window.clearTimeout(fallbackCleanupTimer);
|
||||
window.removeEventListener("afterprint", cleanupPrintMode);
|
||||
document.body.classList.remove("printing-cart");
|
||||
document.title = previousTitle;
|
||||
};
|
||||
window.addEventListener("afterprint", cleanupPrintMode);
|
||||
fallbackCleanupTimer = window.setTimeout(cleanupPrintMode, 30000);
|
||||
window.setTimeout(() => {
|
||||
window.print();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function startSelection() {
|
||||
setMobileMenuOpen(false);
|
||||
setStage(1);
|
||||
@@ -731,11 +1036,196 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
{saveProductMessage && (
|
||||
<div className="product-save-toast" role="status" aria-live="polite">
|
||||
{saveProductMessage}
|
||||
{cartMessage && (
|
||||
<div className="cart-toast" role="status" aria-live="polite">
|
||||
{cartMessage}
|
||||
</div>
|
||||
)}
|
||||
{cartOpen && (
|
||||
<div
|
||||
className="cart-drawer-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setCartOpen(false);
|
||||
}}
|
||||
>
|
||||
<aside className="cart-drawer" role="dialog" aria-modal="true" aria-labelledby="cart-title">
|
||||
<div className="cart-drawer-header">
|
||||
<div>
|
||||
<p className="step-kicker">产品清单</p>
|
||||
<h2 id="cart-title">购物车 <span>{cartCount}</span></h2>
|
||||
</div>
|
||||
<button className="cart-close-button" type="button" onClick={() => setCartOpen(false)} aria-label="关闭购物车">×</button>
|
||||
</div>
|
||||
{cartItems.length > 0 ? (
|
||||
cartSampleFormOpen ? (
|
||||
<div className="cart-sample-scroll">
|
||||
{!cartSampleSubmitted ? (
|
||||
<form className="sample-form cart-sample-form" onSubmit={submitCartSampleRequest}>
|
||||
<div>
|
||||
<p className="step-kicker">购买样品申请</p>
|
||||
<h3>确认购物车样品</h3>
|
||||
<p>以下 {cartItems.length} 个型号、共 {cartCount} 件产品将作为本次样品申请内容。</p>
|
||||
</div>
|
||||
<div className="cart-sample-products">
|
||||
{cartItems.map((item) => (
|
||||
<div key={`sample-${item.product.id}`}>
|
||||
<span>{item.product.name}</span>
|
||||
<code>{item.product.code} · {item.quantity}件</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label><span>公司名称</span><input required name="company" placeholder="请输入公司名称" /></label>
|
||||
<label><span>联系人</span><input required name="name" placeholder="姓名" /></label>
|
||||
<label><span>手机号</span><input required name="phone" inputMode="tel" placeholder="用于样品确认" /></label>
|
||||
<label><span>邮箱(选填)</span><input name="email" type="email" placeholder="接收申请结果" /></label>
|
||||
</div>
|
||||
<label className="full-field"><span>补充说明(选填)</span><textarea name="notes" placeholder="收货信息、使用场景、测试时间或其他要求" /></label>
|
||||
<div className="cart-sample-form-actions">
|
||||
<button className="outline-button" type="button" onClick={() => setCartSampleFormOpen(false)}>返回购物车</button>
|
||||
<button className="panel-primary" type="submit">提交购买样品申请 <span>→</span></button>
|
||||
</div>
|
||||
<small className="demo-disclaimer">当前为内部演示,不会向外部系统发送信息。</small>
|
||||
</form>
|
||||
) : (
|
||||
<div className="success-card cart-sample-success">
|
||||
<div className="success-icon">✓</div>
|
||||
<p className="step-kicker">样品申请已生成</p>
|
||||
<h3>申请编号 {cartSampleLeadId}</h3>
|
||||
<p>已记录购物车中的 {cartCount} 件样品。正式版上线后将自动通知销售与工程师跟进。</p>
|
||||
<button type="button" className="outline-button" onClick={() => setCartSampleFormOpen(false)}>返回购物车</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="cart-list">
|
||||
{cartItems.map((item) => (
|
||||
<div className="cart-item" key={item.product.id}>
|
||||
<div className="cart-item-image">
|
||||
{item.product.image ? <img src={item.product.image} alt="" /> : <span>G</span>}
|
||||
</div>
|
||||
<div className="cart-item-copy">
|
||||
<small>{item.source}</small>
|
||||
<strong>{item.product.name}</strong>
|
||||
<code>{item.product.code}</code>
|
||||
<div className="cart-item-actions">
|
||||
<div className="quantity-control" aria-label={`${item.product.name}数量`}>
|
||||
<button type="button" onClick={() => updateCartQuantity(item.product.id, item.quantity - 1)} aria-label="减少数量">−</button>
|
||||
<span>{item.quantity}</span>
|
||||
<button type="button" onClick={() => updateCartQuantity(item.product.id, item.quantity + 1)} aria-label="增加数量">+</button>
|
||||
</div>
|
||||
<button className="cart-remove-button" type="button" onClick={() => updateCartQuantity(item.product.id, 0)}>移除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cart-drawer-footer">
|
||||
<p>共 {cartCount} 件产品,提交需求时可一并让工程师确认。</p>
|
||||
<div>
|
||||
<button className="outline-button" type="button" onClick={clearCart}>清空购物车</button>
|
||||
<button
|
||||
className="outline-button cart-export-button"
|
||||
type="button"
|
||||
onClick={saveCartList}
|
||||
disabled={cartItems.length === 0 || exportingCart}
|
||||
aria-label={exportingCart ? "正在生成产品清单" : "保存购物车产品列表"}
|
||||
>
|
||||
{exportingCart ? "生成中…" : "保存列表"}
|
||||
</button>
|
||||
<button
|
||||
className="outline-button cart-pdf-button"
|
||||
type="button"
|
||||
onClick={exportCartPdf}
|
||||
disabled={cartItems.length === 0}
|
||||
aria-label="导出购物车 PDF"
|
||||
>
|
||||
导出 PDF
|
||||
</button>
|
||||
<button className="panel-primary" type="button" onClick={() => { setCartOpen(false); startSelection(); }}>继续选型 <span>→</span></button>
|
||||
<button className="panel-primary cart-sample-button" type="button" onClick={openCartSampleForm}>购买样品申请 <span>→</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="cart-empty">
|
||||
<span>🛒</span>
|
||||
<strong>购物车还是空的</strong>
|
||||
<p>浏览产品目录或完成 AI 初选,把需要确认的型号加入这里。</p>
|
||||
<div className="cart-empty-actions">
|
||||
<button className="panel-primary" type="button" onClick={() => setCartOpen(false)}>继续浏览产品 <span>→</span></button>
|
||||
<button className="outline-button cart-export-button" type="button" onClick={saveCartList} disabled aria-label="购物车为空,保存列表不可用">保存列表</button>
|
||||
<button className="outline-button cart-pdf-button" type="button" onClick={exportCartPdf} disabled aria-label="购物车为空,导出 PDF 不可用">导出 PDF</button>
|
||||
<button className="panel-primary cart-sample-button" type="button" onClick={openCartSampleForm} disabled aria-label="购物车为空,购买样品申请不可用">购买样品申请 <span>→</span></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
<section className="cart-print-sheet" aria-hidden="true">
|
||||
<div className="cart-print-header">
|
||||
<div>
|
||||
<p>GAUDELOT · 高徳乐</p>
|
||||
<h1>产品清单</h1>
|
||||
<span>购物车共 {cartItems.length} 个型号 / {cartCount} 件产品</span>
|
||||
</div>
|
||||
<strong>{getCartExportFilename().replace(/\.xlsx$/, ".pdf")}</strong>
|
||||
</div>
|
||||
<table className="cart-print-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>产品图片</th>
|
||||
<th>产品名称</th>
|
||||
<th>产品编码</th>
|
||||
<th>产品分类</th>
|
||||
<th>数量</th>
|
||||
<th>主要参数</th>
|
||||
<th>产品尺寸</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cartItems.map((item) => (
|
||||
<tr key={`print-summary-${item.product.id}`}>
|
||||
<td className="cart-print-image-cell">
|
||||
{item.product.image ? <img src={item.product.image} alt={item.product.name} /> : "暂无图片"}
|
||||
</td>
|
||||
<td>{item.product.name}</td>
|
||||
<td>{item.product.code}</td>
|
||||
<td>{item.product.category}</td>
|
||||
<td>{item.quantity}</td>
|
||||
<td className="cart-print-preline">{getKeySpecText(item.product)}</td>
|
||||
<td>{item.product.specs["产品尺寸"] ?? item.product.specs["外形尺寸"] ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>详细参数</h2>
|
||||
<div className="cart-print-details">
|
||||
{cartItems.map((item) => (
|
||||
<section className="cart-print-product" key={`print-detail-${item.product.id}`}>
|
||||
<div className="cart-print-product-heading">
|
||||
<div>
|
||||
<h3>{item.product.name}</h3>
|
||||
<span>{item.product.code} · {item.product.category} · 数量 {item.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cart-print-spec-grid">
|
||||
{getFullSpecEntries(item.product).map(([label, value]) => (
|
||||
<div key={`${item.product.id}-${label}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<p className="cart-print-footer">高徳乐 AI 舵机选型助手 · 本清单用于客户沟通、选型确认和打印留档,最终参数以正式技术文件为准。</p>
|
||||
</section>
|
||||
<header className="site-header">
|
||||
<a className="brand" href="#top" aria-label="高德乐 AI选型首页">
|
||||
<span className="brand-wordmark">
|
||||
@@ -750,6 +1240,11 @@ export default function Home() {
|
||||
<a href="#how" onClick={() => setMobileMenuOpen(false)}>服务流程</a>
|
||||
</nav>
|
||||
<div className="header-actions">
|
||||
<button className="cart-header-button" type="button" onClick={() => setCartOpen(true)} aria-label={`打开购物车,当前${cartCount}件产品`}>
|
||||
<span aria-hidden="true">🛒</span>
|
||||
<small>购物车</small>
|
||||
<b>{cartCount}</b>
|
||||
</button>
|
||||
<button className="header-cta" type="button" onClick={startSelection}>
|
||||
开始选型 <span>→</span>
|
||||
</button>
|
||||
@@ -1185,17 +1680,12 @@ export default function Home() {
|
||||
<small>价格与交期按用量确认</small>
|
||||
</div>
|
||||
<button
|
||||
className={`product-save-button ${savingProductKey.startsWith(`${match.product.id}-`) ? "is-saving" : ""}`}
|
||||
className="add-cart-button"
|
||||
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}产品参数图片`}
|
||||
onClick={() => addToCart(match.product, "AI初选结果")}
|
||||
aria-label={`将${match.product.name}加入购物车`}
|
||||
>
|
||||
{savingProductKey.startsWith(`${match.product.id}-`) ? "正在生成…" : "保存产品参数图片"} <span>↓</span>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1216,7 +1706,7 @@ export default function Home() {
|
||||
<button className="panel-primary" type="button" onClick={() => setShowSampleForm(true)}>
|
||||
申请样品与工程师复核 <span>→</span>
|
||||
</button>
|
||||
<button className="outline-button" type="button" onClick={() => window.print()}>打印 / 保存方案</button>
|
||||
<button className="outline-button" type="button" onClick={addTopMatchesToCart}>三款加入购物车</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1385,16 +1875,12 @@ export default function Home() {
|
||||
查看完整参数 <span>→</span>
|
||||
</button>
|
||||
<button
|
||||
className={`product-save-button ${savingProductKey === `${product.id}-${product.category}` ? "is-saving" : ""}`}
|
||||
className="add-cart-button"
|
||||
type="button"
|
||||
onClick={() => saveProductCard({
|
||||
product,
|
||||
badge: product.category,
|
||||
subtitle: "完整产品参数",
|
||||
})}
|
||||
aria-label={`保存${product.name}产品参数图片`}
|
||||
onClick={() => addToCart(product)}
|
||||
aria-label={`将${product.name}加入购物车`}
|
||||
>
|
||||
{savingProductKey === `${product.id}-${product.category}` ? "正在生成…" : "保存产品参数图片"} <span>↓</span>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1493,17 +1979,12 @@ export default function Home() {
|
||||
))}
|
||||
</dl>
|
||||
<button
|
||||
className={`product-save-button ${savingProductKey === `${product.id}-${product.subCategory}` ? "is-saving" : ""}`}
|
||||
className="add-cart-button"
|
||||
type="button"
|
||||
onClick={() => saveProductCard({
|
||||
product,
|
||||
badge: "精密齿轮配件",
|
||||
subtitle: product.subCategory,
|
||||
extraSpecs: [["产品子类", product.subCategory]],
|
||||
})}
|
||||
aria-label={`保存${product.name}产品参数图片`}
|
||||
onClick={() => addToCart(product, "精密齿轮配件")}
|
||||
aria-label={`将${product.name}加入购物车`}
|
||||
>
|
||||
{savingProductKey === `${product.id}-${product.subCategory}` ? "正在生成…" : "保存产品参数图片"} <span>↓</span>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1611,16 +2092,12 @@ export default function Home() {
|
||||
用AI继续确认需求 <span>→</span>
|
||||
</button>
|
||||
<button
|
||||
className={`product-save-button modal-save-button ${savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "is-saving" : ""}`}
|
||||
className="add-cart-button modal-cart-button"
|
||||
type="button"
|
||||
onClick={() => saveProductCard({
|
||||
product: selectedCatalogProduct,
|
||||
badge: selectedCatalogProduct.category,
|
||||
subtitle: "完整产品参数",
|
||||
})}
|
||||
aria-label={`保存${selectedCatalogProduct.name}产品参数图片`}
|
||||
onClick={() => addToCart(selectedCatalogProduct)}
|
||||
aria-label={`将${selectedCatalogProduct.name}加入购物车`}
|
||||
>
|
||||
{savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "正在生成…" : "保存产品参数图片"} <span>↓</span>
|
||||
加入到购物车 <span>+</span>
|
||||
</button>
|
||||
<p>具体价格、交期及最终适配结果以工程师确认和正式文件为准。</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user