From 254cd92c5b6aa7b0a9090f4def6632658d7e82f3 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 15:45:18 +0800 Subject: [PATCH 1/4] fix(product-archive): stream material library images through a same-origin endpoint the material library api hands out signed preview urls whose objects 404 on its cdn, so archive rows kept rendering empty placeholders; the batch endpoint now returns same-origin streaming urls (material-image?file=1) that resolve server-side, try the reachable s3 direct link first with the original signed url as fallback, and stream the bytes to the browser; the resolved image object is copied before url mapping so the service in-memory cache is no longer poisoned across requests. --- .../product-archive/material-image/route.ts | 53 ++++++++++++++++++- .../product-archive/material-images/route.ts | 20 +++++++ .../product-archive-material-image.service.ts | 6 +++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts index 79355391..1c83120f 100644 --- a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts @@ -1,13 +1,18 @@ -import { NextRequest } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { productArchiveMaterialImageService } from '@/server/services/product-archive-material-image.service' +import { + getReachableMaterialPreviewUrlCandidates, + productArchiveMaterialImageService, +} from '@/server/services/product-archive-material-image.service' const querySchema = z.object({ spu: z.string().trim().max(120).optional(), barcode: z.string().trim().max(120).optional(), sku: z.string().trim().max(120).optional(), + archiveId: z.string().trim().max(120).optional(), + file: z.enum(['1']).optional(), }).refine((value) => Boolean(value.barcode || value.sku), { message: '条码和 SKU 至少提供一个', }) @@ -20,6 +25,8 @@ export async function GET(request: NextRequest) { spu: request.nextUrl.searchParams.get('spu') || undefined, barcode: request.nextUrl.searchParams.get('barcode') || undefined, sku: request.nextUrl.searchParams.get('sku') || undefined, + archiveId: request.nextUrl.searchParams.get('archiveId') || undefined, + file: request.nextUrl.searchParams.get('file') || undefined, }) if (!parsed.success) { @@ -32,6 +39,48 @@ export async function GET(request: NextRequest) { } const image = await productArchiveMaterialImageService.resolve(parsed.data) + + if (!image?.previewUrl) { + return jsonError( + ErrorCode.FILE_NOT_FOUND, + '未匹配到素材库图片', + 404, + ctx.requestId, + ) + } + + // file=1:以图片字节流返回,浏览器无需直接访问外部素材库地址 + if (parsed.data.file === '1') { + for (const candidate of getReachableMaterialPreviewUrlCandidates(image.previewUrl)) { + try { + const upstream = await fetch(candidate, { + headers: { Accept: 'image/avif,image/webp,image/*,*/*;q=0.8' }, + cache: 'no-store', + signal: request.signal, + }) + const contentType = upstream.headers.get('content-type') || '' + if (upstream.ok && contentType.startsWith('image/')) { + return new NextResponse(upstream.body, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'private, max-age=3600', + }, + }) + } + } catch { + // 尝试下一个候选地址 + } + } + + return jsonError( + ErrorCode.FILE_NOT_FOUND, + '素材图片拉取失败', + 404, + ctx.requestId, + ) + } + return jsonSuccess(image, 200, ctx.requestId) }, { diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts index b6088f12..c0b17f96 100644 --- a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts @@ -17,6 +17,17 @@ const batchSchema = z.object({ items: z.array(batchItemSchema).min(1).max(200), }).strict() +type BatchSourceItem = z.infer + +// 让浏览器经由本应用同源地址读取图片字节,而不是直接加载素材库外部地址 +function buildSameOriginImageUrl(item: BatchSourceItem) { + const params = new URLSearchParams({ archiveId: item.archiveId, file: '1' }) + if (item.spu) params.set('spu', item.spu) + if (item.barcode) params.set('barcode', item.barcode) + if (item.sku) params.set('sku', item.sku) + return `/api/supply-chain/product-archive/material-image?${params.toString()}` +} + export async function POST(request: NextRequest) { return handleRequest( request, @@ -36,6 +47,15 @@ export async function POST(request: NextRequest) { parsed.data.items, request.signal, ) + + for (const [index, item] of result.items.entries()) { + const source = parsed.data.items[index] + if (item.status === 'resolved' && item.image?.previewUrl) { + // 拷贝对象再改写,避免污染服务层内存缓存里的同一个对象 + item.image = { ...item.image, previewUrl: buildSameOriginImageUrl(source) } + } + } + return jsonSuccess(result, 200, ctx.requestId) }, { diff --git a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts index bf0cf024..2fdc66ba 100644 --- a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts +++ b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts @@ -453,5 +453,11 @@ export class ProductArchiveMaterialImageService { } } +// 输出可直接被浏览器加载的候选地址:优先已回退到 s3 直链的地址,其次原始地址(如七牛签名地址) +export function getReachableMaterialPreviewUrlCandidates(previewUrl: string): string[] { + const rewritten = readReachableMaterialHttpUrl(previewUrl) ?? previewUrl + return rewritten === previewUrl ? [previewUrl] : [rewritten, previewUrl] +} + export const productArchiveMaterialImageService = new ProductArchiveMaterialImageService() From 1c0beffcb650b47e483c6d260aae7db4768430d4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 15:43:08 +0800 Subject: [PATCH 2/4] feat(barcode-print): add calibrated Gujia certificate template --- .../barcode-print/barcodePrintCertificate.ts | 12 ++++++++ .../barcodePrintGujiaCertificate2026.ts | 28 +++++++++++++++++++ ...barcodePrintGujiaCertificateLayout.test.ts | 12 ++++---- .../barcodePrintGujiaCertificateLayout.ts | 4 ++- 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts index 1a676988..f3f2a230 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts @@ -1,6 +1,7 @@ "use client"; import html2canvas from "html2canvas"; +import { applyGujiaCertificate2026Content } from "./barcodePrintGujiaCertificate2026"; import { GUJIA_CERTIFICATE_CANVAS, getGujiaCertificateFieldStyle, @@ -104,6 +105,7 @@ const SOFIA_CARTON_TITLE = "索菲亚外箱贴纸(10cm*7cm)"; const COMMON_WITH_MAKER_TITLE = "通用横版贴纸-带供应商(10cm*7cm)"; const COMMON_WITHOUT_MAKER_TITLE = "通用横版贴纸-不带供应商(10cm*7cm)"; const GUJIA_CERTIFICATE_TITLE = "顾家合格证卡片(11cm*13cm)"; +const GUJIA_CERTIFICATE_2026_TITLE = "顾家合格证卡片2026新版(11cm*13cm)"; const CERTIFICATE_FONT_FAMILY = "Microsoft YaHei, 微软雅黑, Arial, sans-serif"; const LANDSCAPE_FIELD_TOP = 302; @@ -741,6 +743,16 @@ const CERTIFICATE_TEMPLATES: CertificateTemplate[] = [ background: "hgz_gj.jpg", render: renderGujiaCertificate, }, + { + title: GUJIA_CERTIFICATE_2026_TITLE, + pageSize: "110mm 130mm", + background: "hgz_gj.jpg", + render: (record, qrCodeBase64, options, backgroundDataUrl) => + applyGujiaCertificate2026Content( + renderGujiaCertificate(record, qrCodeBase64, options, backgroundDataUrl), + record.executionStandard, + ), + }, ]; export const CERTIFICATE_PRINT_TEMPLATE_OPTIONS = CERTIFICATE_TEMPLATES.map( diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts new file mode 100644 index 00000000..e97552b3 --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts @@ -0,0 +1,28 @@ +import { getGujiaCertificateFieldStyle } from "./barcodePrintGujiaCertificateLayout"; + +export function applyGujiaCertificate2026Content( + baseCertificate: HTMLElement, + executionStandard: string, +): HTMLElement { + // 新版使用独立节点,保留旧版的八个字段坐标与字体。 + const certificate = baseCertificate.cloneNode(true) as HTMLElement; + certificate.className = "certificate_main_gj_2026"; + + const manufacturer = certificate.querySelector(".item_5"); + const qualityLevel = certificate.querySelector(".item_7"); + const productionDate = certificate.querySelector(".item_8"); + if (!manufacturer || !qualityLevel || !productionDate) { + throw new Error("顾家合格证基础字段不完整。"); + } + + manufacturer.textContent = "欧伯娜家居有限公司"; + qualityLevel.textContent = "合格品"; + + const standard = productionDate.cloneNode(false) as HTMLElement; + standard.className = "item_9 item"; + standard.textContent = executionStandard.trim(); + Object.assign(standard.style, getGujiaCertificateFieldStyle(8)); + certificate.appendChild(standard); + + return certificate; +} diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts index 48ded5fe..dc94237d 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts @@ -1,5 +1,6 @@ import { GUJIA_CERTIFICATE_CANVAS, + GUJIA_FIELD_PRINT_VERTICAL_OFFSET, getGujiaCertificateFieldStyle, } from "./barcodePrintGujiaCertificateLayout"; @@ -18,11 +19,12 @@ describe("Gujia certificate layout", () => { } }); - it("uses the 2026 118.5px row spacing for exactly eight fields", () => { - expect(getGujiaCertificateFieldStyle(0).top).toBe("508px"); - expect(getGujiaCertificateFieldStyle(1).top).toBe("626.5px"); - expect(getGujiaCertificateFieldStyle(2).top).toBe("745px"); - expect(getGujiaCertificateFieldStyle(7).top).toBe("1337.5px"); + it("moves all dynamic fields up 6mm while preserving the 118.5px row spacing", () => { + expect(GUJIA_FIELD_PRINT_VERTICAL_OFFSET).toBe(-72); + expect(getGujiaCertificateFieldStyle(0).top).toBe("436px"); + expect(getGujiaCertificateFieldStyle(1).top).toBe("554.5px"); + expect(getGujiaCertificateFieldStyle(2).top).toBe("673px"); + expect(getGujiaCertificateFieldStyle(7).top).toBe("1265.5px"); }); it("keeps the original Gujia canvas dimensions", () => { diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts index ec88480f..6f89ccea 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts @@ -4,7 +4,9 @@ export const GUJIA_CERTIFICATE_CANVAS = { } as const; const GUJIA_FIELD_LEFT = 474; -const GUJIA_FIELD_TOP = 508; +// 实物卡片校准:动态字段相对红色底图整体上移 6mm(1323px / 110mm)。 +export const GUJIA_FIELD_PRINT_VERTICAL_OFFSET = -72; +const GUJIA_FIELD_TOP = 508 + GUJIA_FIELD_PRINT_VERTICAL_OFFSET; const GUJIA_FIELD_ROW_GAP = 118.5; export function getGujiaCertificateFieldStyle( From 0271da1b6988655719a729caf8ab8bb639aec9b0 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 17:54:04 +0800 Subject: [PATCH 3/4] fix(barcode-print): recalibrate Gujia card and disable 2026 template --- .../BarcodePrintTemplateDialog.test.tsx | 29 ++++++++++- .../BarcodePrintTemplateDialog.tsx | 21 ++++++-- .../barcodePrintCertificate.test.ts | 49 ++++++++++++++++++- .../barcode-print/barcodePrintCertificate.ts | 19 +++++-- ...barcodePrintGujiaCertificateLayout.test.ts | 14 +++++- .../barcodePrintGujiaCertificateLayout.ts | 10 ++-- 6 files changed, 128 insertions(+), 14 deletions(-) diff --git a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx index 72e727a6..6d46748d 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx +++ b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx @@ -1,8 +1,35 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BarcodePrintTemplateDialog } from './BarcodePrintTemplateDialog' describe('BarcodePrintTemplateDialog', () => { + it('marks a disabled certificate unavailable, skips a stale default and prevents submission', async () => { + const actor = userEvent.setup() + const onSubmit = jest.fn() + const disabledTitle = '顾家合格证卡片2026新版(11cm*13cm)' + const legacyTitle = '顾家合格证卡片(11cm*13cm)' + render( + , + ) + const select = screen.getByLabelText('打印模板') + expect(screen.getByRole('option', { name: `${disabledTitle}(暂时不可用)` })).toBeDisabled() + expect(select).toHaveValue(legacyTitle) + await actor.selectOptions(select, disabledTitle) + expect(select).toHaveValue(legacyTitle) + fireEvent.change(select, { target: { value: disabledTitle } }) + await actor.click(screen.getByRole('button', { name: '确定' })) + expect(onSubmit).not.toHaveBeenCalled() + await actor.selectOptions(select, legacyTitle) + await actor.click(screen.getByRole('button', { name: '确定' })) + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ templateTitle: legacyTitle })) + }) + it('submits the selected barcode template', async () => { const actor = userEvent.setup() const onSubmit = jest.fn() diff --git a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx index 5729c3c4..02c0e53d 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx +++ b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx @@ -6,6 +6,7 @@ import type { CertificateSpecialEditMode } from "./barcodePrintCertificate"; type PrintTemplateOption = { title: string; + disabled?: boolean; editMode?: CertificateSpecialEditMode; }; @@ -36,9 +37,9 @@ export function BarcodePrintTemplateDialog({ }) { const resolvedDefaultTemplateTitle = useMemo( () => - templates.some((template) => template.title === defaultTemplateTitle) + templates.some((template) => template.title === defaultTemplateTitle && !template.disabled) ? defaultTemplateTitle ?? "" - : (templates[0]?.title ?? ""), + : (templates.find((template) => !template.disabled)?.title ?? ""), [defaultTemplateTitle, templates], ); const [templateTitle, setTemplateTitle] = useState(resolvedDefaultTemplateTitle); @@ -66,10 +67,14 @@ export function BarcodePrintTemplateDialog({ if (!isOpen) return null; function submit() { - if (!templateTitle) { + if (!selectedTemplate) { setErrorMessage("请选择打印模板。"); return; } + if (selectedTemplate.disabled) { + setErrorMessage("该模板暂时不可用。"); + return; + } onSubmit({ templateTitle, @@ -125,8 +130,13 @@ export function BarcodePrintTemplateDialog({ > {templates.map((template) => ( - ))} @@ -155,6 +165,7 @@ export function BarcodePrintTemplateDialog({