Add cart exports and sample request workflow

This commit is contained in:
Jacky Ser
2026-08-13 11:07:56 +08:00
parent 511dbcd0e8
commit 5101493901
4 changed files with 1662 additions and 104 deletions

View File

@@ -49,6 +49,10 @@ a { color: inherit; text-decoration: none; }
.site-header nav { display: flex; gap: 34px; font-size: 14px; color: #536078; }
.site-header nav a:hover { color: var(--blue); }
.header-actions { display: flex; align-items: center; gap: 8px; }
.cart-header-button { position: relative; min-height: 38px; padding: 7px 12px 7px 9px; display: inline-flex; align-items: center; gap: 5px; border: 1px solid #dbe4f1; border-radius: 10px; color: #536078; background: white; cursor: pointer; font-size: 10px; font-weight: 750; }
.cart-header-button:hover { border-color: #a9c4f2; color: var(--blue); background: #f7faff; }
.cart-header-button > span { font-size: 15px; line-height: 1; }
.cart-header-button b { min-width: 17px; height: 17px; padding: 0 4px; display: inline-grid; place-items: center; border-radius: 999px; color: white; background: var(--blue); font-size: 9px; }
.header-cta {
border: 0; border-radius: 10px; padding: 11px 17px;
background: var(--ink); color: white; cursor: pointer; font-weight: 650;
@@ -124,8 +128,8 @@ a { color: inherit; text-decoration: none; }
.step-kicker { margin: 0 0 7px; color: var(--blue); font-size: 11px; letter-spacing: .08em; font-weight: 800; text-transform: uppercase; }
.question-step h2, .result-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
.step-help { margin: 7px 0 19px; color: #7c8799; font-size: 12px; }
.choice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.choice-card { position: relative; min-height: 76px; padding: 14px 35px 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #dfe5ee; border-radius: 11px; background: white; cursor: pointer; }
.choice-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
.choice-card { position: relative; min-height: 68px; padding: 11px 30px 11px 13px; display: grid; align-content: center; gap: 3px; text-align: left; border: 1px solid #dfe5ee; border-radius: 11px; background: white; cursor: pointer; }
.choice-card:hover { border-color: #a8c3f4; background: #fbfdff; }
.choice-card.selected { border-color: var(--blue); background: var(--blue-soft); box-shadow: inset 0 0 0 1px var(--blue); }
.choice-card b { font-size: 13px; }
@@ -375,12 +379,102 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
font-size: 10px;
font-weight: 700;
}
.catalog-card-body > .product-save-button { margin-top: 5px; padding: 8px 10px; border: 1px solid #d8e3f3; border-radius: 8px; color: #185ee8; background: #f7faff; }
.product-save-button { width: 100%; min-height: 33px; margin-top: 10px; padding: 8px 10px; display: inline-flex; align-items: center; justify-content: space-between; border: 1px solid #d8e3f3; border-radius: 8px; color: #185ee8; background: #f7faff; cursor: pointer; font-size: 10px; font-weight: 750; }
.product-save-button:hover { border-color: #a9c4f2; color: #104ec6; background: #eef5ff; }
.product-save-button span { font-size: 13px; line-height: 1; }
.product-save-button.is-saving { visibility: hidden; }
.product-save-toast { position: fixed; left: 50%; bottom: 24px; z-index: 120; transform: translateX(-50%); max-width: min(90vw, 360px); padding: 10px 15px; border: 1px solid #cbdaf2; border-radius: 999px; color: #174baf; background: rgba(248,251,255,.96); box-shadow: 0 12px 32px rgba(24,64,126,.18); font-size: 11px; font-weight: 750; text-align: center; backdrop-filter: blur(10px); }
.add-cart-button { width: 100%; min-height: 33px; margin-top: 10px; padding: 8px 10px; display: inline-flex; align-items: center; justify-content: space-between; border: 1px solid #c9dafa; border-radius: 8px; color: #185ee8; background: #f4f8ff; cursor: pointer; font-size: 10px; font-weight: 750; }
.add-cart-button:hover { border-color: #8eb2f2; color: #104ec6; background: #eaf2ff; }
.add-cart-button span { font-size: 14px; line-height: 1; }
.catalog-card-body > .add-cart-button { margin-top: 5px; }
.precision-gear-card-body .add-cart-button { margin-top: 10px; }
.recommendation-content .add-cart-button { margin-top: 10px; }
.cart-toast { position: fixed; left: 50%; bottom: 24px; z-index: 120; transform: translateX(-50%); max-width: min(90vw, 380px); padding: 10px 15px; border: 1px solid #cbdaf2; border-radius: 999px; color: #174baf; background: rgba(248,251,255,.96); box-shadow: 0 12px 32px rgba(24,64,126,.18); font-size: 11px; font-weight: 750; text-align: center; backdrop-filter: blur(10px); }
.cart-drawer-backdrop { position: fixed; inset: 0; z-index: 110; display: flex; justify-content: flex-end; background: rgba(7,14,29,.45); backdrop-filter: blur(4px); }
.cart-drawer { width: min(430px, 100%); height: 100%; padding: 28px 24px 22px; display: flex; flex-direction: column; overflow: hidden; background: white; box-shadow: -18px 0 50px rgba(20,43,82,.2); }
.cart-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; padding-bottom: 17px; border-bottom: 1px solid #e5eaf1; }
.cart-drawer-header .step-kicker { margin-bottom: 4px; }
.cart-drawer-header h2 { margin: 0; color: var(--ink); font-size: 24px; letter-spacing: -.03em; }
.cart-drawer-header h2 span { color: var(--blue); font-size: 16px; }
.cart-close-button { width: 34px; height: 34px; border: 0; border-radius: 50%; color: #627086; background: #eef2f7; cursor: pointer; font-size: 20px; }
.cart-list { flex: 1; display: grid; align-content: start; gap: 10px; padding: 16px 2px; overflow-y: auto; }
.cart-item { min-width: 0; padding: 10px; display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 11px; border: 1px solid #dce5f2; border-radius: 11px; background: #f9fbfe; }
.cart-item-image { width: 64px; height: 64px; display: grid; place-items: center; overflow: hidden; border-radius: 8px; background: #eef3fa; }
.cart-item-image img { display: block; width: 100%; height: 100%; object-fit: contain; mix-blend-mode: multiply; }
.cart-item-image span { color: #7ea1dd; font-weight: 800; }
.cart-item-copy { min-width: 0; display: grid; align-content: start; gap: 3px; }
.cart-item-copy small { color: var(--blue); font-size: 8px; font-weight: 750; }
.cart-item-copy strong { overflow: hidden; color: var(--ink); font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.cart-item-copy code { overflow: hidden; color: #8995a6; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.cart-item-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 5px; }
.quantity-control { display: inline-flex; align-items: center; border: 1px solid #d7e1ee; border-radius: 7px; overflow: hidden; background: white; }
.quantity-control button { width: 25px; height: 24px; padding: 0; border: 0; color: var(--blue); background: white; cursor: pointer; font-size: 15px; }
.quantity-control span { min-width: 24px; color: #52617a; font-size: 10px; text-align: center; }
.cart-remove-button { padding: 3px 0; border: 0; color: #a27b7b; background: transparent; cursor: pointer; font-size: 9px; }
.cart-remove-button:hover { color: #d34f4f; }
.cart-drawer-footer { padding-top: 14px; border-top: 1px solid #e5eaf1; }
.cart-drawer-footer p { margin: 0 0 12px; color: #7f8b9e; font-size: 9px; line-height: 1.45; }
.cart-drawer-footer > div { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.cart-drawer-footer .outline-button, .cart-drawer-footer .panel-primary { width: 100%; min-width: 0; min-height: 39px; padding: 0 6px; flex: none; font-size: 10px; line-height: 1; white-space: nowrap; }
.cart-drawer-footer .panel-primary span { margin-left: 5px; font-size: 9px; }
.cart-drawer-footer .cart-sample-button { grid-column: 1 / -1; color: white; background: #185ee8; }
.cart-drawer-footer .cart-sample-button:hover { color: white; background: #124fc9; }
.cart-drawer-footer .cart-sample-button:disabled, .cart-empty-actions .cart-sample-button:disabled { color: #aeb9c9; border-color: #dfe5ee; background: #f6f8fb; box-shadow: none; cursor: not-allowed; }
.cart-export-button { color: var(--blue); border-color: #b9cdf1; }
.cart-export-button:hover { color: #124ac0; border-color: var(--blue); background: #f4f8ff; }
.cart-export-button:disabled { color: #aeb9c9; border-color: #dfe5ee; background: #f6f8fb; cursor: not-allowed; }
.cart-pdf-button { color: #a85d2a; border-color: #edc9ac; }
.cart-pdf-button:hover { color: #874317; border-color: #d99565; background: #fff8f2; }
.cart-pdf-button:disabled { color: #bdb4ad; border-color: #e9e2dc; background: #faf8f6; cursor: not-allowed; }
.cart-empty { margin: auto 0; padding: 30px 12px; display: grid; justify-items: center; gap: 9px; text-align: center; }
.cart-empty > span { font-size: 36px; }
.cart-empty strong { color: var(--ink); font-size: 16px; }
.cart-empty p { max-width: 270px; margin: 0 0 8px; color: #7f8b9e; font-size: 10px; line-height: 1.6; }
.cart-empty-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; gap: 7px; }
.cart-empty-actions .panel-primary, .cart-empty-actions .cart-export-button { min-height: 42px; }
.cart-empty-actions .cart-pdf-button { min-height: 42px; }
.cart-empty-actions .outline-button, .cart-empty-actions .panel-primary { min-width: 0; padding: 0 6px; font-size: 10px; line-height: 1; white-space: nowrap; }
.cart-empty-actions .cart-sample-button { grid-column: 1 / -1; }
.cart-sample-scroll { flex: 1; overflow-y: auto; padding: 16px 2px; }
.cart-sample-form { margin-top: 0; }
.cart-sample-products { display: grid; gap: 6px; margin: 10px 0 14px; padding: 9px; border: 1px solid #dbe5f2; border-radius: 9px; background: #f8faff; }
.cart-sample-products > div { min-width: 0; display: grid; gap: 2px; padding-bottom: 6px; border-bottom: 1px solid #e8eef6; }
.cart-sample-products > div:last-child { padding-bottom: 0; border-bottom: 0; }
.cart-sample-products span { overflow: hidden; color: var(--ink); font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.cart-sample-products code { overflow: hidden; color: #8995a6; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.cart-sample-form-actions { display: grid; grid-template-columns: 1fr 1.4fr; gap: 8px; margin-top: 10px; }
.cart-sample-form-actions .outline-button, .cart-sample-form-actions .panel-primary { width: 100%; min-width: 0; min-height: 39px; padding: 0 8px; font-size: 10px; white-space: nowrap; }
.cart-sample-success { margin-top: 18px; }
.cart-print-sheet { display: none; }
.cart-print-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 18px; border-bottom: 2px solid #185ee8; }
.cart-print-header p { margin: 0 0 7px; color: #185ee8; font-size: 10pt; font-weight: 800; letter-spacing: .08em; }
.cart-print-header h1 { margin: 0 0 6px; color: #17233a; font-size: 24pt; line-height: 1.1; }
.cart-print-header span, .cart-print-header strong { color: #71809a; font-size: 9pt; }
.cart-print-header strong { padding-top: 25px; font-weight: 600; }
.cart-print-summary { width: 100%; margin-top: 18px; border-collapse: collapse; table-layout: fixed; color: #17233a; font-size: 8.5pt; }
.cart-print-summary th, .cart-print-summary td { padding: 6px; border: 1px solid #dbe4ef; vertical-align: middle; overflow-wrap: anywhere; }
.cart-print-summary th { color: white; background: #185ee8; font-weight: 800; text-align: center; }
.cart-print-summary th:nth-child(1) { width: 12%; }
.cart-print-summary th:nth-child(2) { width: 19%; }
.cart-print-summary th:nth-child(3) { width: 14%; }
.cart-print-summary th:nth-child(4) { width: 12%; }
.cart-print-summary th:nth-child(5) { width: 7%; }
.cart-print-summary th:nth-child(6) { width: 24%; }
.cart-print-summary th:nth-child(7) { width: 12%; }
.cart-print-summary td:nth-child(5) { text-align: center; font-weight: 800; }
.cart-print-image-cell { height: 66px; text-align: center; }
.cart-print-image-cell img { width: 78px; height: 54px; object-fit: contain; }
.cart-print-preline { white-space: pre-line; }
.cart-print-sheet > h2 { margin: 25px 0 10px; padding-bottom: 6px; border-bottom: 1px solid #cbd8ea; color: #185ee8; font-size: 15pt; }
.cart-print-details { display: grid; gap: 12px; }
.cart-print-product { break-inside: avoid; page-break-inside: avoid; border: 1px solid #dbe4ef; }
.cart-print-product-heading { padding: 9px 11px; background: #f3f7fd; }
.cart-print-product-heading h3 { margin: 0 0 4px; color: #17233a; font-size: 11pt; }
.cart-print-product-heading span { color: #71809a; font-size: 8pt; }
.cart-print-spec-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.cart-print-spec-grid div { min-height: 32px; padding: 6px 10px; border-top: 1px solid #e3eaf3; }
.cart-print-spec-grid div:nth-child(4n + 1), .cart-print-spec-grid div:nth-child(4n + 2), .cart-print-spec-grid div:nth-child(4n + 3) { border-right: 1px solid #e3eaf3; }
.cart-print-spec-grid span, .cart-print-spec-grid strong { display: block; overflow-wrap: anywhere; }
.cart-print-spec-grid span { margin-bottom: 3px; color: #8492a7; font-size: 7.5pt; }
.cart-print-spec-grid strong { color: #17233a; font-size: 8.5pt; line-height: 1.35; }
.cart-print-footer { margin: 20px 0 0; padding-top: 10px; border-top: 1px solid #dbe4ef; color: #8492a7; font-size: 7.5pt; }
.precision-gear-panel {
display: grid;
grid-template-columns: minmax(260px, .8fr) 1.2fr;
@@ -431,7 +525,6 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
.precision-gear-card-body dl div { display: flex; justify-content: space-between; gap: 5px; padding: 2px 0; font-size: 8px; }
.precision-gear-card-body dt { color: #8a95a7; }
.precision-gear-card-body dd { max-width: 62%; margin: 0; overflow: hidden; color: #52617a; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-weight: 700; }
.precision-gear-card-body .product-save-button { margin-top: 10px; }
.precision-gear-empty { margin-top: 15px; }
.precision-gear-load-more { margin-top: 22px; }
.catalog-empty { padding: 60px 20px; text-align: center; border: 1px dashed #cbd5e3; border-radius: 15px; background: white; }
@@ -507,7 +600,7 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
.catalog-spec-table strong { font-size: 10.5px; line-height: 1.2; }
.catalog-modal-actions { display: flex; align-items: center; gap: 18px; margin-top: 20px; }
.catalog-modal-actions .panel-primary { flex: 0 0 auto; }
.catalog-modal-actions .modal-save-button { width: auto; min-width: 124px; margin-top: 0; flex: 0 0 auto; }
.catalog-modal-actions .modal-cart-button { width: auto; min-width: 124px; margin-top: 0; flex: 0 0 auto; }
.catalog-modal-actions p { margin: 0; color: #8b95a5; font-size: 9px; line-height: 1.6; }
.how-section { padding: 100px max(32px, calc((100vw - 1180px) / 2)); display: grid; grid-template-columns: .9fr 1.1fr; gap: 100px; color: white; background: linear-gradient(135deg, #0c1528, #14284e); }
.eyebrow.light { color: #77a5ff; }
@@ -535,6 +628,7 @@ footer > span { color: #9aa4b2; font-size: 10px; }
.precision-gear-panel { grid-template-columns: 1fr; }
.precision-gear-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.catalog-spec-table { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.choice-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
@@ -548,6 +642,9 @@ footer > span { color: #9aa4b2; font-size: 10px; }
.site-header nav a:hover { background: var(--blue-soft); }
.header-cta { padding: 9px 11px; font-size: 11px; }
.header-cta span { margin-left: 4px; }
.cart-header-button { min-height: 36px; padding: 7px 7px; }
.cart-header-button small { display: none; }
.cart-header-button > span { font-size: 16px; }
.mobile-menu-toggle { min-width: 52px; padding: 7px 8px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #dce5f2; border-radius: 10px; color: var(--blue); background: white; cursor: pointer; }
.mobile-menu-toggle span { font-size: 18px; line-height: 1; }
.mobile-menu-toggle small { font-size: 10px; font-weight: 700; }
@@ -559,7 +656,12 @@ footer > span { color: #9aa4b2; font-size: 10px; }
.selector-shell { border-radius: 16px; }
.question-panel, .result-panel { padding: 20px 16px; }
.welcome-panel { padding: 55px 20px 35px; }
.choice-grid, .two-fields, .parameter-grid, .form-grid, .recommendation-grid { grid-template-columns: 1fr; }
.two-fields, .parameter-grid, .form-grid, .recommendation-grid { grid-template-columns: 1fr; }
.choice-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.choice-card { min-height: 62px; padding: 9px 27px 9px 10px; border-radius: 9px; }
.choice-card b { font-size: 11px; }
.choice-card small { font-size: 8px; line-height: 1.3; }
.choice-check { right: 8px; top: 8px; width: 16px; height: 16px; font-size: 9px; }
.torque-presets { grid-template-columns: repeat(4, 1fr); }
.recommendation-card { min-height: 0; }
.capability-strip { grid-template-columns: 1fr 1fr; gap: 24px 0; padding: 28px 16px; }
@@ -619,7 +721,7 @@ footer > span { color: #9aa4b2; font-size: 10px; }
.catalog-spec-table span { font-size: 7.5px; }
.catalog-spec-table strong { font-size: 8.5px; line-height: 1.15; }
.catalog-modal-actions { align-items: stretch; flex-direction: column; }
.catalog-modal-actions .modal-save-button { width: 100%; }
.catalog-modal-actions .modal-cart-button { width: 100%; }
.products-section { padding: 75px 16px; }
.how-section { grid-template-columns: 1fr; padding: 75px 20px; }
footer { grid-template-columns: 1fr; justify-items: center; gap: 15px; }
@@ -631,8 +733,11 @@ footer > span { color: #9aa4b2; font-size: 10px; }
}
@media print {
.site-header, .hero-copy, .capability-strip, .products-section, .how-section, footer, .selector-topbar, .result-actions, .restart-button { display: none !important; }
.hero { display: block; min-height: auto; padding: 0; background: white; }
.selector-shell { box-shadow: none; border: 0; }
.result-panel { padding: 20px; }
@page { size: A4; margin: 12mm 10mm; }
body.printing-cart { margin: 0; background: white; }
body.printing-cart > * { visibility: hidden !important; }
body.printing-cart .cart-print-sheet,
body.printing-cart .cart-print-sheet * { visibility: visible !important; }
body.printing-cart .cart-print-sheet { position: absolute; inset: 0; display: block !important; width: 100%; color: #17233a; background: white; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
body.printing-cart .site-header, body.printing-cart .hero-copy, body.printing-cart .capability-strip, body.printing-cart .products-section, body.printing-cart .how-section, body.printing-cart footer, body.printing-cart .selector-topbar, body.printing-cart .result-actions, body.printing-cart .restart-button { display: none !important; }
}

View File

@@ -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>

985
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"drizzle-orm": "0.45.2",
"exceljs": "^4.4.0",
"react": "19.2.6",
"react-dom": "19.2.6"
},