Add product card image saving
This commit is contained in:
@@ -358,10 +358,10 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
|||||||
.catalog-card-body { padding: 16px; }
|
.catalog-card-body { padding: 16px; }
|
||||||
.catalog-card-body > p { margin: 0 0 5px; color: #8793a5; font-size: 9px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
.catalog-card-body > p { margin: 0 0 5px; color: #8793a5; font-size: 9px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
.catalog-card-body h3 { height: 42px; margin: 0 0 12px; overflow: hidden; font-size: 14px; line-height: 1.5; }
|
.catalog-card-body h3 { height: 42px; margin: 0 0 12px; overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||||
.catalog-card-body dl { min-height: 83px; margin: 0; padding: 10px; border-radius: 9px; background: #f6f8fb; }
|
.catalog-card-body dl { min-height: 0; margin: 0; padding: 10px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; border-radius: 9px; background: #f6f8fb; }
|
||||||
.catalog-card-body dl div { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; font-size: 9px; }
|
.catalog-card-body dl div { min-width: 0; display: grid; align-content: start; gap: 4px; padding: 3px 0; font-size: 9px; }
|
||||||
.catalog-card-body dt { color: #8a95a7; }
|
.catalog-card-body dt { overflow: hidden; color: #8a95a7; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.catalog-card-body dd { max-width: 58%; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; font-weight: 650; }
|
.catalog-card-body dd { max-width: 100%; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-weight: 650; }
|
||||||
.catalog-card-body > button {
|
.catalog-card-body > button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -375,6 +375,12 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
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); }
|
||||||
.precision-gear-panel {
|
.precision-gear-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, .8fr) 1.2fr;
|
grid-template-columns: minmax(260px, .8fr) 1.2fr;
|
||||||
@@ -425,6 +431,7 @@ 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 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 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 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-empty { margin-top: 15px; }
|
||||||
.precision-gear-load-more { margin-top: 22px; }
|
.precision-gear-load-more { margin-top: 22px; }
|
||||||
.catalog-empty { padding: 60px 20px; text-align: center; border: 1px dashed #cbd5e3; border-radius: 15px; background: white; }
|
.catalog-empty { padding: 60px 20px; text-align: center; border: 1px dashed #cbd5e3; border-radius: 15px; background: white; }
|
||||||
@@ -492,12 +499,15 @@ select:focus, input:focus, textarea:focus { border-color: var(--blue); box-shado
|
|||||||
.catalog-modal-inline-info small { color: #96a0af; font-size: 8px; }
|
.catalog-modal-inline-info small { color: #96a0af; font-size: 8px; }
|
||||||
.catalog-modal-image { min-height: min(56vh, 520px); display: flex; align-items: center; justify-content: center; border: 1px solid #e1e7f0; border-radius: 13px; overflow: hidden; background: #f5f7fb; }
|
.catalog-modal-image { min-height: min(56vh, 520px); display: flex; align-items: center; justify-content: center; border: 1px solid #e1e7f0; border-radius: 13px; overflow: hidden; background: #f5f7fb; }
|
||||||
.catalog-modal-image img { display: block; max-width: 100%; max-height: min(56vh, 510px); width: 100%; height: auto; object-fit: contain; mix-blend-mode: multiply; }
|
.catalog-modal-image img { display: block; max-width: 100%; max-height: min(56vh, 510px); width: 100%; height: auto; object-fit: contain; mix-blend-mode: multiply; }
|
||||||
.catalog-spec-table { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin-top: 18px; overflow: hidden; border: 1px solid #e0e5ed; border-radius: 11px; background: #e0e5ed; }
|
.catalog-spec-table { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin-top: 18px; overflow: hidden; border: 1px solid #e0e5ed; border-radius: 11px; background: #e0e5ed; }
|
||||||
.catalog-spec-table div { min-height: 38px; padding: 7px 10px; display: grid; align-content: center; gap: 3px; background: white; }
|
.catalog-spec-table div { min-width: 0; min-height: 58px; padding: 10px 12px; display: grid; align-content: center; gap: 4px; background: white; }
|
||||||
.catalog-spec-table span { color: #8b96a7; font-size: 8px; }
|
.catalog-spec-table span,
|
||||||
.catalog-spec-table strong { font-size: 10px; }
|
.catalog-spec-table strong { min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
||||||
|
.catalog-spec-table span { color: #8b96a7; font-size: 8px; line-height: 1.35; }
|
||||||
|
.catalog-spec-table strong { font-size: 10px; line-height: 1.4; }
|
||||||
.catalog-modal-actions { display: flex; align-items: center; gap: 18px; margin-top: 20px; }
|
.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 .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 p { margin: 0; color: #8b95a5; font-size: 9px; line-height: 1.6; }
|
.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); }
|
.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; }
|
.eyebrow.light { color: #77a5ff; }
|
||||||
@@ -524,6 +534,7 @@ footer > span { color: #9aa4b2; font-size: 10px; }
|
|||||||
.catalog-grid { grid-template-columns: repeat(2, 1fr); }
|
.catalog-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.precision-gear-panel { grid-template-columns: 1fr; }
|
.precision-gear-panel { grid-template-columns: 1fr; }
|
||||||
.precision-gear-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.precision-gear-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.catalog-spec-table { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -592,7 +603,10 @@ footer > span { color: #9aa4b2; font-size: 10px; }
|
|||||||
.precision-gear-card-body { padding: 9px; }
|
.precision-gear-card-body { padding: 9px; }
|
||||||
.catalog-card-body { padding: 15px; }
|
.catalog-card-body { padding: 15px; }
|
||||||
.catalog-card-body h3 { height: auto; min-height: 42px; font-size: 14px; }
|
.catalog-card-body h3 { height: auto; min-height: 42px; font-size: 14px; }
|
||||||
.catalog-card-body dl { min-height: 90px; }
|
.catalog-card-body dl { min-height: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 9px 8px; }
|
||||||
|
.catalog-card-body dl div { min-width: 0; display: grid; align-content: start; justify-content: initial; gap: 3px; padding: 2px 0; font-size: 8px; }
|
||||||
|
.catalog-card-body dt { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.catalog-card-body dd { max-width: 100%; overflow: hidden; font-size: 9px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.catalog-modal-backdrop { padding: 10px; }
|
.catalog-modal-backdrop { padding: 10px; }
|
||||||
.catalog-modal { padding: 22px 16px; }
|
.catalog-modal { padding: 22px 16px; }
|
||||||
.catalog-modal-visual-toolbar { align-items: flex-start; flex-direction: column; gap: 10px; }
|
.catalog-modal-visual-toolbar { align-items: flex-start; flex-direction: column; gap: 10px; }
|
||||||
@@ -600,8 +614,12 @@ footer > span { color: #9aa4b2; font-size: 10px; }
|
|||||||
.catalog-modal-inline-info h2 { font-size: 18px; }
|
.catalog-modal-inline-info h2 { font-size: 18px; }
|
||||||
.catalog-modal-image { min-height: 290px; }
|
.catalog-modal-image { min-height: 290px; }
|
||||||
.catalog-modal-image img { max-height: 290px; }
|
.catalog-modal-image img { max-height: 290px; }
|
||||||
.catalog-spec-table { grid-template-columns: 1fr; }
|
.catalog-spec-table { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.catalog-spec-table div { min-height: 52px; padding: 8px 9px; gap: 3px; }
|
||||||
|
.catalog-spec-table span { font-size: 8px; }
|
||||||
|
.catalog-spec-table strong { font-size: 9px; }
|
||||||
.catalog-modal-actions { align-items: stretch; flex-direction: column; }
|
.catalog-modal-actions { align-items: stretch; flex-direction: column; }
|
||||||
|
.catalog-modal-actions .modal-save-button { width: 100%; }
|
||||||
.products-section { padding: 75px 16px; }
|
.products-section { padding: 75px 16px; }
|
||||||
.how-section { grid-template-columns: 1fr; padding: 75px 20px; }
|
.how-section { grid-template-columns: 1fr; padding: 75px 20px; }
|
||||||
footer { grid-template-columns: 1fr; justify-items: center; gap: 15px; }
|
footer { grid-template-columns: 1fr; justify-items: center; gap: 15px; }
|
||||||
|
|||||||
298
app/page.tsx
298
app/page.tsx
@@ -350,6 +350,193 @@ function Metric({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SaveProductRequest = {
|
||||||
|
product: CatalogProduct;
|
||||||
|
badge?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
extraSpecs?: Array<[string, string]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSaveProductSpecs(product: CatalogProduct, extraSpecs: Array<[string, string]> = []) {
|
||||||
|
return [
|
||||||
|
...extraSpecs.filter(([, value]) => value),
|
||||||
|
...Object.entries(product.specs).filter(([, value]) => value),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapCanvasText(context: CanvasRenderingContext2D, value: string, maxWidth: number, maxLines = 2) {
|
||||||
|
const characters = Array.from(String(value));
|
||||||
|
const lines: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const character of characters) {
|
||||||
|
const next = current + character;
|
||||||
|
if (current && context.measureText(next).width > maxWidth) {
|
||||||
|
lines.push(current);
|
||||||
|
current = character;
|
||||||
|
if (lines.length === maxLines) break;
|
||||||
|
} else {
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length < maxLines && current) lines.push(current);
|
||||||
|
if (lines.length === maxLines && characters.join("").length > lines.join("").length) {
|
||||||
|
const last = lines[maxLines - 1] ?? "";
|
||||||
|
lines[maxLines - 1] = `${last.slice(0, Math.max(1, last.length - 1))}…`;
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRoundedRect(
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
fill: string,
|
||||||
|
stroke?: string,
|
||||||
|
) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x + radius, y);
|
||||||
|
context.lineTo(x + width - radius, y);
|
||||||
|
context.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
context.lineTo(x + width, y + height - radius);
|
||||||
|
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
context.lineTo(x + radius, y + height);
|
||||||
|
context.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
context.lineTo(x, y + radius);
|
||||||
|
context.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
context.closePath();
|
||||||
|
context.fillStyle = fill;
|
||||||
|
context.fill();
|
||||||
|
if (stroke) {
|
||||||
|
context.strokeStyle = stroke;
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProductCanvasImage(src: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error(`产品图片加载失败:${src}`));
|
||||||
|
image.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProductCardPng({ product, badge, subtitle, extraSpecs = [] }: SaveProductRequest) {
|
||||||
|
const specs = getSaveProductSpecs(product, extraSpecs);
|
||||||
|
const columnCount = 2;
|
||||||
|
const rowCount = Math.max(1, Math.ceil(specs.length / columnCount));
|
||||||
|
const cardWidth = 1200;
|
||||||
|
const padding = 52;
|
||||||
|
const headerHeight = 152;
|
||||||
|
const specCellHeight = 70;
|
||||||
|
const specGap = 10;
|
||||||
|
const mainHeight = Math.max(390, rowCount * specCellHeight + (rowCount - 1) * specGap + 28);
|
||||||
|
const cardHeight = padding + headerHeight + 24 + mainHeight + 24 + 42 + padding;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = cardWidth;
|
||||||
|
canvas.height = cardHeight;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("当前浏览器不支持图片生成");
|
||||||
|
|
||||||
|
context.fillStyle = "#eef3fa";
|
||||||
|
context.fillRect(0, 0, cardWidth, cardHeight);
|
||||||
|
drawRoundedRect(context, 18, 18, cardWidth - 36, cardHeight - 36, 26, "#ffffff", "#d9e3f2");
|
||||||
|
context.fillStyle = "#185ee8";
|
||||||
|
context.fillRect(padding, padding, 110, 7);
|
||||||
|
|
||||||
|
context.fillStyle = "#123267";
|
||||||
|
context.font = "800 25px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
context.fillText("GAUDELOT", padding, padding + 34);
|
||||||
|
context.fillStyle = "#7b8ca8";
|
||||||
|
context.font = "600 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
context.fillText("高徳乐产品资料", padding + 145, padding + 33);
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
const badgeWidth = Math.max(100, context.measureText(badge).width + 34);
|
||||||
|
drawRoundedRect(context, cardWidth - padding - badgeWidth, padding + 8, badgeWidth, 32, 16, "#edf4ff");
|
||||||
|
context.fillStyle = "#185ee8";
|
||||||
|
context.font = "700 14px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
context.fillText(badge, cardWidth - padding - badgeWidth + 17, padding + 29);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillStyle = "#111d34";
|
||||||
|
context.font = "800 30px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
const titleLines = wrapCanvasText(context, product.name, 760, 2);
|
||||||
|
titleLines.forEach((line, index) => context.fillText(line, padding, padding + 82 + index * 36));
|
||||||
|
context.fillStyle = "#71809a";
|
||||||
|
context.font = "600 17px ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||||
|
context.fillText(product.code, padding, padding + 145);
|
||||||
|
if (subtitle) {
|
||||||
|
context.fillStyle = "#8290a5";
|
||||||
|
context.font = "500 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
context.fillText(subtitle, padding + 240, padding + 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainY = padding + headerHeight + 24;
|
||||||
|
const imageX = padding;
|
||||||
|
const imageWidth = 430;
|
||||||
|
const infoX = imageX + imageWidth + 24;
|
||||||
|
const infoWidth = cardWidth - padding - infoX;
|
||||||
|
drawRoundedRect(context, imageX, mainY, imageWidth, mainHeight, 18, "#f3f7fd", "#dce6f3");
|
||||||
|
drawRoundedRect(context, infoX, mainY, infoWidth, mainHeight, 18, "#f8faff", "#dce6f3");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!product.image) throw new Error("没有产品主图");
|
||||||
|
const image = await loadProductCanvasImage(product.image);
|
||||||
|
const maxImageWidth = imageWidth - 34;
|
||||||
|
const maxImageHeight = mainHeight - 34;
|
||||||
|
const scale = Math.min(maxImageWidth / image.naturalWidth, maxImageHeight / image.naturalHeight);
|
||||||
|
const imageWidthOnCanvas = image.naturalWidth * scale;
|
||||||
|
const imageHeightOnCanvas = image.naturalHeight * scale;
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
imageX + (imageWidth - imageWidthOnCanvas) / 2,
|
||||||
|
mainY + (mainHeight - imageHeightOnCanvas) / 2,
|
||||||
|
imageWidthOnCanvas,
|
||||||
|
imageHeightOnCanvas,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
context.fillStyle = "#9eb3d3";
|
||||||
|
context.font = "800 22px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
context.fillText("GAUDELOT", imageX + 132, mainY + mainHeight / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fillStyle = "#6f809b";
|
||||||
|
context.font = "700 15px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
context.fillText("主要参数", infoX + 22, mainY + 31);
|
||||||
|
const cellsTop = mainY + 48;
|
||||||
|
const cellWidth = (infoWidth - 44 - specGap) / columnCount;
|
||||||
|
specs.forEach(([label, value], index) => {
|
||||||
|
const column = index % columnCount;
|
||||||
|
const row = Math.floor(index / columnCount);
|
||||||
|
const cellX = infoX + 22 + column * (cellWidth + specGap);
|
||||||
|
const cellY = cellsTop + row * (specCellHeight + specGap);
|
||||||
|
drawRoundedRect(context, cellX, cellY, cellWidth, specCellHeight, 10, "#ffffff", "#e4ebf4");
|
||||||
|
context.fillStyle = "#8b98ab";
|
||||||
|
context.font = "600 13px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
const labelLines = wrapCanvasText(context, label, cellWidth - 24, 1);
|
||||||
|
context.fillText(labelLines[0] ?? label, cellX + 12, cellY + 22);
|
||||||
|
context.fillStyle = "#15233d";
|
||||||
|
context.font = "800 16px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
const valueLines = wrapCanvasText(context, value, cellWidth - 24, 2);
|
||||||
|
valueLines.forEach((line, lineIndex) => context.fillText(line, cellX + 12, cellY + 47 + lineIndex * 18));
|
||||||
|
});
|
||||||
|
|
||||||
|
context.fillStyle = "#8b98ab";
|
||||||
|
context.font = "500 13px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
||||||
|
context.fillText("高徳乐官方产品目录 · 仅供选型参考,具体参数以正式文件为准", padding, cardHeight - padding - 11);
|
||||||
|
|
||||||
|
return new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("PNG生成失败"))), "image/png");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [stage, setStage] = useState(0);
|
const [stage, setStage] = useState(0);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
@@ -371,6 +558,8 @@ export default function Home() {
|
|||||||
const [precisionGearFiltersOpen, setPrecisionGearFiltersOpen] = useState(false);
|
const [precisionGearFiltersOpen, setPrecisionGearFiltersOpen] = useState(false);
|
||||||
const [selectedCatalogProduct, setSelectedCatalogProduct] = useState<CatalogProduct | null>(null);
|
const [selectedCatalogProduct, setSelectedCatalogProduct] = useState<CatalogProduct | null>(null);
|
||||||
const [catalogVisualMode, setCatalogVisualMode] = useState<CatalogVisualMode>("main");
|
const [catalogVisualMode, setCatalogVisualMode] = useState<CatalogVisualMode>("main");
|
||||||
|
const [savingProductKey, setSavingProductKey] = useState("");
|
||||||
|
const [saveProductMessage, setSaveProductMessage] = useState("");
|
||||||
|
|
||||||
const matches = useMemo(() => getMatches(requirements), [requirements]);
|
const matches = useMemo(() => getMatches(requirements), [requirements]);
|
||||||
const topMatches = matches.slice(0, 3);
|
const topMatches = matches.slice(0, 3);
|
||||||
@@ -458,6 +647,60 @@ export default function Home() {
|
|||||||
setCatalogVisualMode("main");
|
setCatalogVisualMode("main");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveProductCard(request: SaveProductRequest) {
|
||||||
|
if (savingProductKey) return;
|
||||||
|
const saveKey = `${request.product.id}-${request.badge ?? "product"}`;
|
||||||
|
setSavingProductKey(saveKey);
|
||||||
|
setSaveProductMessage("正在生成产品图片…");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await renderProductCardPng(request);
|
||||||
|
const safeName = `${request.product.code}-${request.product.name}`
|
||||||
|
.replace(/[\\/:*?"<>|]/g, "-")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.slice(0, 90);
|
||||||
|
const fileName = `${safeName || request.product.id}.png`;
|
||||||
|
const file = new File([blob], fileName, { type: "image/png" });
|
||||||
|
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||||
|
(navigator.maxTouchPoints > 0 && window.innerWidth <= 900);
|
||||||
|
const canShareFile = typeof navigator.share === "function" &&
|
||||||
|
(!navigator.canShare || navigator.canShare({ files: [file] }));
|
||||||
|
|
||||||
|
if (isMobile && canShareFile) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: `${request.product.name} 产品资料`,
|
||||||
|
text: `高徳乐${request.product.category} · ${request.product.code}`,
|
||||||
|
files: [file],
|
||||||
|
});
|
||||||
|
setSaveProductMessage("已打开系统分享面板");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
setSaveProductMessage("已取消保存");
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
setSaveProductMessage("PNG图片已下载");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存产品图片失败", error);
|
||||||
|
setSaveProductMessage("图片生成失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setSavingProductKey("");
|
||||||
|
window.setTimeout(() => setSaveProductMessage(""), 2800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startSelection() {
|
function startSelection() {
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
setStage(1);
|
setStage(1);
|
||||||
@@ -488,6 +731,11 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
|
{saveProductMessage && (
|
||||||
|
<div className="product-save-toast" role="status" aria-live="polite">
|
||||||
|
{saveProductMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<a className="brand" href="#top" aria-label="高德乐 AI选型首页">
|
<a className="brand" href="#top" aria-label="高德乐 AI选型首页">
|
||||||
<span className="brand-wordmark">
|
<span className="brand-wordmark">
|
||||||
@@ -936,6 +1184,19 @@ export default function Home() {
|
|||||||
<strong>{match.product.category}</strong>
|
<strong>{match.product.category}</strong>
|
||||||
<small>价格与交期按用量确认</small>
|
<small>价格与交期按用量确认</small>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className={`product-save-button ${savingProductKey.startsWith(`${match.product.id}-`) ? "is-saving" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveProductCard({
|
||||||
|
product: match.product,
|
||||||
|
badge: index === 0 ? (match.coreCompatible ? "首选推荐" : "待复核候选") : `${match.confidence}% 匹配`,
|
||||||
|
subtitle: `${match.product.category} · ${match.product.size || "尺寸待复核"}`,
|
||||||
|
extraSpecs: [["AI匹配度", `${match.confidence}%`]],
|
||||||
|
})}
|
||||||
|
aria-label={`保存${match.product.name}产品图片`}
|
||||||
|
>
|
||||||
|
{savingProductKey.startsWith(`${match.product.id}-`) ? "正在生成…" : "保存产品"} <span>↓</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -1123,6 +1384,18 @@ export default function Home() {
|
|||||||
<button type="button" onClick={() => openCatalogProduct(product)}>
|
<button type="button" onClick={() => openCatalogProduct(product)}>
|
||||||
查看完整参数 <span>→</span>
|
查看完整参数 <span>→</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`product-save-button ${savingProductKey === `${product.id}-${product.category}` ? "is-saving" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveProductCard({
|
||||||
|
product,
|
||||||
|
badge: product.category,
|
||||||
|
subtitle: "完整产品参数",
|
||||||
|
})}
|
||||||
|
aria-label={`保存${product.name}产品图片`}
|
||||||
|
>
|
||||||
|
{savingProductKey === `${product.id}-${product.category}` ? "正在生成…" : "保存产品"} <span>↓</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -1219,6 +1492,19 @@ export default function Home() {
|
|||||||
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
|
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
<button
|
||||||
|
className={`product-save-button ${savingProductKey === `${product.id}-${product.subCategory}` ? "is-saving" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveProductCard({
|
||||||
|
product,
|
||||||
|
badge: "精密齿轮配件",
|
||||||
|
subtitle: product.subCategory,
|
||||||
|
extraSpecs: [["产品子类", product.subCategory]],
|
||||||
|
})}
|
||||||
|
aria-label={`保存${product.name}产品图片`}
|
||||||
|
>
|
||||||
|
{savingProductKey === `${product.id}-${product.subCategory}` ? "正在生成…" : "保存产品"} <span>↓</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -1324,6 +1610,18 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
用AI继续确认需求 <span>→</span>
|
用AI继续确认需求 <span>→</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`product-save-button modal-save-button ${savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "is-saving" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveProductCard({
|
||||||
|
product: selectedCatalogProduct,
|
||||||
|
badge: selectedCatalogProduct.category,
|
||||||
|
subtitle: "完整产品参数",
|
||||||
|
})}
|
||||||
|
aria-label={`保存${selectedCatalogProduct.name}产品图片`}
|
||||||
|
>
|
||||||
|
{savingProductKey === `${selectedCatalogProduct.id}-${selectedCatalogProduct.category}` ? "正在生成…" : "保存产品"} <span>↓</span>
|
||||||
|
</button>
|
||||||
<p>具体价格、交期及最终适配结果以工程师确认和正式文件为准。</p>
|
<p>具体价格、交期及最终适配结果以工程师确认和正式文件为准。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user