fix(barcode-print): 索菲亚合格证/箱贴模板修复 + 顾家布局微调

索菲亚相关:
- barcodePrintSofiaCartonLayout 新增索菲亚箱贴布局
- barcodePrintCertificate 证书渲染逻辑调整
- BarcodePrintCertificateEditDialog 编辑弹窗重构

顾家相关:
- barcodePrintGujiaCertificateLayout 布局微调

纯前端/打印逻辑,不涉及数据库结构,可安全部署。
This commit is contained in:
WuYongJie 2026-08-06 14:33:24 +08:00
parent 5904e96494
commit 4d24976ab9
7 changed files with 339 additions and 123 deletions

View File

@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BarcodePrintCertificateEditDialog } from './BarcodePrintCertificateEditDialog'
import { getSofiaCartonInputStyle } from './barcodePrintSofiaCartonLayout'
describe('BarcodePrintCertificateEditDialog', () => {
it('shows Sofia carton editable fields before printing', async () => {
@ -32,6 +33,9 @@ describe('BarcodePrintCertificateEditDialog', () => {
expect(screen.getByRole('dialog')).toHaveAccessibleName('索菲亚外箱贴纸预览')
expect(screen.getByRole('button', { name: '上传线稿图' })).toBeInTheDocument()
expect(screen.getByLabelText('颜色')).toHaveStyle(
getSofiaCartonInputStyle('color'),
)
await actor.type(screen.getByLabelText('净重'), '10kg')
await actor.type(screen.getByLabelText('毛重'), '12kg')

View File

@ -8,6 +8,12 @@ import type {
CommonCertificateForm,
SofiaCartonCertificateForm,
} from "./barcodePrintCertificate";
import {
getSofiaCartonInputStyle,
SOFIA_CARTON_EDIT_LINE_ART_LAYOUT,
SOFIA_CARTON_EDIT_CANVAS,
type SofiaCartonValueField,
} from "./barcodePrintSofiaCartonLayout";
const TEMPLATE_BASE = "/barcode-print/templates";
@ -57,15 +63,18 @@ function commonInputStyle({
return miniInputStyle({ left, top, width: 170 });
}
function titleStyle(top: number) {
function sofiaInputStyle(key: SofiaCartonValueField) {
return {
position: "absolute",
left: 250,
top,
color: "#000000",
fontSize: 20,
fontWeight: 700,
lineHeight: 1,
...getSofiaCartonInputStyle(key),
boxSizing: "border-box",
padding: "0 6px",
border: "1px solid #dcdfe6",
borderRadius: 4,
color: "#606266",
fontSize: 10,
lineHeight: "20px",
outline: "none",
background: "#ffffff",
} satisfies CSSProperties;
}
@ -187,19 +196,39 @@ export function BarcodePrintCertificateEditDialog({
className="mx-auto"
style={{
position: "relative",
width: 590,
height: 423,
width: SOFIA_CARTON_EDIT_CANVAS.width,
height: SOFIA_CARTON_EDIT_CANVAS.height,
background: `url(${TEMPLATE_BASE}/wxtz_sfy.jpg) no-repeat center center`,
backgroundSize: "cover",
}}
>
<span style={titleStyle(160.5)}>
<span
style={{
position: "absolute",
left: 250,
top: 160.5,
lineHeight: 1,
fontSize: 20,
fontWeight: "bold",
}}
>
{[sofiaCartonForm.productCategory, sofiaCartonForm.color]
.map(textValue)
.filter(Boolean)
.join("-")}
</span>
<span style={titleStyle(195.5)}>{sofiaCartonForm.barcode}</span>
<span
style={{
position: "absolute",
left: 250,
top: 195.5,
lineHeight: 1,
fontSize: 20,
fontWeight: "bold",
}}
>
{sofiaCartonForm.barcode}
</span>
<input
ref={lineArtInputRef}
type="file"
@ -213,12 +242,12 @@ export function BarcodePrintCertificateEditDialog({
{sofiaCartonForm.lineArtUrl ? (
<div
style={{
position: "absolute",
left: 5,
top: 114,
position: "absolute",
left: SOFIA_CARTON_EDIT_LINE_ART_LAYOUT.left,
top: SOFIA_CARTON_EDIT_LINE_ART_LAYOUT.top,
display: "flex",
width: 223,
height: 167,
width: SOFIA_CARTON_EDIT_LINE_ART_LAYOUT.width,
height: SOFIA_CARTON_EDIT_LINE_ART_LAYOUT.height,
alignItems: "center",
justifyContent: "center",
}}
@ -241,61 +270,14 @@ export function BarcodePrintCertificateEditDialog({
上传线稿图
</button>
)}
<input
aria-label="颜色"
readOnly
value={sofiaCartonForm.color}
style={miniInputStyle({ left: 314, top: 229, width: 90 })}
/>
<input
aria-label="净重"
placeholder="请填写净重"
value={sofiaCartonForm.JZ}
onChange={(event) => updateSofiaField("JZ", event.target.value)}
style={miniInputStyle({ left: 314, top: 252, width: 90 })}
/>
<input
aria-label="毛重"
placeholder="请填写毛重"
value={sofiaCartonForm.MZ}
onChange={(event) => updateSofiaField("MZ", event.target.value)}
style={miniInputStyle({ left: 476, top: 252, width: 90 })}
/>
<input
aria-label="外箱体积"
placeholder="请填写外箱体积"
value={sofiaCartonForm.WXTJ}
onChange={(event) => updateSofiaField("WXTJ", event.target.value)}
style={miniInputStyle({ left: 314, top: 275, width: 90 })}
/>
<input
aria-label="外箱尺寸"
placeholder="请填写外箱尺寸"
value={sofiaCartonForm.WXCC}
onChange={(event) => updateSofiaField("WXCC", event.target.value)}
style={miniInputStyle({ left: 314, top: 298, width: 90 })}
/>
<input
aria-label="箱号"
placeholder="请填箱号"
value={sofiaCartonForm.XH}
onChange={(event) => updateSofiaField("XH", event.target.value)}
style={miniInputStyle({ left: 314, top: 321, width: 90 })}
/>
<input
aria-label="装箱数量"
placeholder="请填写装箱数量"
value={sofiaCartonForm.ZXSL}
onChange={(event) => updateSofiaField("ZXSL", event.target.value)}
style={miniInputStyle({ left: 314, top: 344, width: 90 })}
/>
<input
aria-label="产地"
placeholder="请填写产地"
value={sofiaCartonForm.CD}
onChange={(event) => updateSofiaField("CD", event.target.value)}
style={miniInputStyle({ left: 314, top: 367, width: 90 })}
/>
<input aria-label="颜色" readOnly value={sofiaCartonForm.color} style={sofiaInputStyle("color")} />
<input aria-label="净重" placeholder="请填写净重" value={sofiaCartonForm.JZ} onChange={(event) => updateSofiaField("JZ", event.target.value)} style={sofiaInputStyle("JZ")} />
<input aria-label="毛重" placeholder="请填写毛重" value={sofiaCartonForm.MZ} onChange={(event) => updateSofiaField("MZ", event.target.value)} style={sofiaInputStyle("MZ")} />
<input aria-label="外箱体积" placeholder="请填写外箱体积" value={sofiaCartonForm.WXTJ} onChange={(event) => updateSofiaField("WXTJ", event.target.value)} style={sofiaInputStyle("WXTJ")} />
<input aria-label="外箱尺寸" placeholder="请填写外箱尺寸" value={sofiaCartonForm.WXCC} onChange={(event) => updateSofiaField("WXCC", event.target.value)} style={sofiaInputStyle("WXCC")} />
<input aria-label="箱号" placeholder="请填箱号" value={sofiaCartonForm.XH} onChange={(event) => updateSofiaField("XH", event.target.value)} style={sofiaInputStyle("XH")} />
<input aria-label="装箱数量" placeholder="请填写装箱数量" value={sofiaCartonForm.ZXSL} onChange={(event) => updateSofiaField("ZXSL", event.target.value)} style={sofiaInputStyle("ZXSL")} />
<input aria-label="产地" placeholder="请填写产地" value={sofiaCartonForm.CD} onChange={(event) => updateSofiaField("CD", event.target.value)} style={sofiaInputStyle("CD")} />
</div>
) : null}

View File

@ -6,6 +6,12 @@ import {
GUJIA_CERTIFICATE_CANVAS,
getGujiaCertificateFieldStyle,
} from "./barcodePrintGujiaCertificateLayout";
import {
getSofiaCartonTitleStyle,
getSofiaCartonValueStyle,
SOFIA_CARTON_CANVAS,
SOFIA_CARTON_LINE_ART_LAYOUT,
} from "./barcodePrintSofiaCartonLayout";
import type { BarcodePrintRow } from "./barcodePrintRows";
type CertificateTemplate = {
@ -447,7 +453,6 @@ function renderGujiaCertificate(
"B",
"合格",
getLast10DayText(),
"",
].forEach((text, index) => {
appendItem(
container,
@ -460,22 +465,6 @@ function renderGujiaCertificate(
return container;
}
function appendSofiaCartonItem(
container: HTMLElement,
text: string,
top: number,
left = 634,
) {
appendItem(container, "item", text, {
position: "absolute",
left: `${left}px`,
top: `${top}px`,
fontSize: "24px",
lineHeight: "1",
fontWeight: "bold",
});
}
function renderSofiaCarton(
record: CertificateRecord,
_qrCodeBase64: string,
@ -488,20 +477,25 @@ function renderSofiaCarton(
.map((value) => value.trim())
.filter(Boolean)
.join("-");
const container = createCertificateMain("certificate_main_sfy_wxtz", backgroundDataUrl, 1181, 827);
const container = createCertificateMain(
"certificate_main_sfy_wxtz",
backgroundDataUrl,
SOFIA_CARTON_CANVAS.width,
SOFIA_CARTON_CANVAS.height,
);
if (form.lineArtUrl) {
const lineArtContainer = createElement("div", "blob-container");
const image = document.createElement("img");
image.src = form.lineArtUrl;
Object.assign(lineArtContainer.style, {
position: "absolute",
width: "446px",
height: "354px",
width: `${SOFIA_CARTON_LINE_ART_LAYOUT.width}px`,
height: `${SOFIA_CARTON_LINE_ART_LAYOUT.height}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
left: "10px",
top: "228px",
left: `${SOFIA_CARTON_LINE_ART_LAYOUT.left}px`,
top: `${SOFIA_CARTON_LINE_ART_LAYOUT.top}px`,
zIndex: "1",
} satisfies Partial<CSSStyleDeclaration>);
Object.assign(image.style, {
@ -513,29 +507,25 @@ function renderSofiaCarton(
container.appendChild(lineArtContainer);
}
appendItem(container, "title productCategory", productLabel, {
position: "absolute",
left: "500px",
top: "321px",
lineHeight: "1",
fontSize: "40px",
fontWeight: "bold",
...getSofiaCartonTitleStyle("productCategory"),
});
appendItem(container, "title barcode", form.barcode, {
position: "absolute",
left: "500px",
top: "391px",
lineHeight: "1",
fontSize: "40px",
fontWeight: "bold",
...getSofiaCartonTitleStyle("barcode"),
});
(
[
["color", form.color],
["JZ", form.JZ],
["MZ", form.MZ],
["WXTJ", form.WXTJ],
["WXCC", form.WXCC],
["XH", form.XH],
["ZXSL", form.ZXSL],
["CD", form.CD],
] as const
).forEach(([key, text]) => {
appendItem(container, "item", text, getSofiaCartonValueStyle(key));
});
appendSofiaCartonItem(container, form.color, 454);
appendSofiaCartonItem(container, form.JZ, 500);
appendSofiaCartonItem(container, form.MZ, 500, 952);
appendSofiaCartonItem(container, form.WXTJ, 546);
appendSofiaCartonItem(container, form.WXCC, 592);
appendSofiaCartonItem(container, form.XH, 638);
appendSofiaCartonItem(container, form.ZXSL, 684);
appendSofiaCartonItem(container, form.CD, 730);
return container;
}

View File

@ -4,23 +4,25 @@ import {
} from "./barcodePrintGujiaCertificateLayout";
describe("Gujia certificate layout", () => {
it("keeps every value left-aligned from the original printed field boundary", () => {
it("uses the 2026 Gujia field boundary and the template default font", () => {
for (let index = 0; index < 8; index += 1) {
expect(getGujiaCertificateFieldStyle(index)).toEqual(
expect.objectContaining({
position: "absolute",
fontSize: "42px",
lineHeight: "48px",
left: "455px",
fontFamily: "",
left: "474px",
}),
);
}
});
it("uses the original 101px row spacing without raising the product name", () => {
expect(getGujiaCertificateFieldStyle(0).top).toBe("530px");
expect(getGujiaCertificateFieldStyle(1).top).toBe("631px");
expect(getGujiaCertificateFieldStyle(2).top).toBe("732px");
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("keeps the original Gujia canvas dimensions", () => {

View File

@ -3,9 +3,9 @@ export const GUJIA_CERTIFICATE_CANVAS = {
height: 1559,
} as const;
const GUJIA_FIELD_LEFT = 455;
const GUJIA_FIELD_TOP = 530;
const GUJIA_FIELD_ROW_GAP = 101;
const GUJIA_FIELD_LEFT = 474;
const GUJIA_FIELD_TOP = 508;
const GUJIA_FIELD_ROW_GAP = 118.5;
export function getGujiaCertificateFieldStyle(
index: number,
@ -17,6 +17,7 @@ export function getGujiaCertificateFieldStyle(
fontSize: "42px",
color: "#000000",
lineHeight: "48px",
fontFamily: "",
left: `${GUJIA_FIELD_LEFT}px`,
top: `${top}px`,
};

View File

@ -0,0 +1,75 @@
import {
getSofiaCartonInputStyle,
getSofiaCartonTitleStyle,
getSofiaCartonValueStyle,
SOFIA_CARTON_CANVAS,
SOFIA_CARTON_LINE_ART_LAYOUT,
SOFIA_CARTON_MANUFACTURER_SAFE_TOP,
SOFIA_CARTON_EDIT_CANVAS,
SOFIA_CARTON_EDIT_SCALE,
SOFIA_CARTON_BARCODE_TEXT_TOP_OFFSET,
SOFIA_CARTON_RIGHT_CONTENT_TOP_OFFSET,
SOFIA_CARTON_VALUE_GROUP_TOP_OFFSET,
SOFIA_CARTON_TITLE_LAYOUT,
SOFIA_CARTON_TITLE_TEXT_TOP_OFFSET,
SOFIA_CARTON_VALUE_LAYOUT,
SOFIA_CARTON_VALUE_TEXT_TOP_OFFSET,
} from "./barcodePrintSofiaCartonLayout";
describe("Sofia carton layout", () => {
it("keeps the edit canvas as an exact half-scale of the print canvas", () => {
expect(SOFIA_CARTON_CANVAS).toEqual({ width: 1181, height: 827 });
expect(SOFIA_CARTON_EDIT_SCALE).toBe(0.5);
expect(SOFIA_CARTON_EDIT_CANVAS).toEqual({ width: 590, height: 423 });
});
it("uses the same visible field coordinates in edit and print layouts", () => {
Object.keys(SOFIA_CARTON_VALUE_LAYOUT).forEach((field) => {
const key = field as keyof typeof SOFIA_CARTON_VALUE_LAYOUT;
const layout = SOFIA_CARTON_VALUE_LAYOUT[key];
expect(getSofiaCartonValueStyle(key)).toEqual(
expect.objectContaining({
left: `${layout.left}px`,
top: `${layout.top + SOFIA_CARTON_VALUE_TEXT_TOP_OFFSET + SOFIA_CARTON_VALUE_GROUP_TOP_OFFSET}px`,
}),
);
expect(getSofiaCartonInputStyle(key)).toEqual(
expect.objectContaining({
left: `${layout.inputLeft}px`,
top: `${layout.inputTop}px`,
width: `${layout.inputWidth}px`,
height: `${layout.inputHeight}px`,
}),
);
});
});
it("keeps the line-art area above the fixed manufacturer text", () => {
expect(
SOFIA_CARTON_LINE_ART_LAYOUT.top + SOFIA_CARTON_LINE_ART_LAYOUT.height,
).toBeLessThan(SOFIA_CARTON_MANUFACTURER_SAFE_TOP);
});
it("uses a stable centered value row with an explicit font baseline correction", () => {
const style = getSofiaCartonValueStyle("color");
expect(style).toEqual(
expect.objectContaining({
top: `${SOFIA_CARTON_VALUE_LAYOUT.color.top + SOFIA_CARTON_VALUE_TEXT_TOP_OFFSET + SOFIA_CARTON_VALUE_GROUP_TOP_OFFSET}px`,
lineHeight: "1",
}),
);
});
it("applies the same baseline correction to the title and barcode", () => {
expect(
getSofiaCartonTitleStyle("productCategory").top,
).toBe(
`${SOFIA_CARTON_TITLE_LAYOUT.productCategory.top + SOFIA_CARTON_TITLE_TEXT_TOP_OFFSET + SOFIA_CARTON_RIGHT_CONTENT_TOP_OFFSET}px`,
);
expect(getSofiaCartonTitleStyle("barcode").top).toBe(
`${SOFIA_CARTON_TITLE_LAYOUT.barcode.top + SOFIA_CARTON_BARCODE_TEXT_TOP_OFFSET + SOFIA_CARTON_RIGHT_CONTENT_TOP_OFFSET}px`,
);
});
});

View File

@ -0,0 +1,162 @@
export const SOFIA_CARTON_FONT_FAMILY =
"Microsoft YaHei, 微软雅黑, Arial, sans-serif";
export const SOFIA_CARTON_CANVAS = {
width: 1181,
height: 827,
} as const;
export const SOFIA_CARTON_EDIT_SCALE = 0.5;
export const SOFIA_CARTON_EDIT_CANVAS = {
width: 590,
height: 423,
} as const;
export const SOFIA_CARTON_LINE_ART_LAYOUT = {
left: 10,
top: 228,
width: 446,
// Keep the uploaded image above the manufacturer's fixed text in the base image.
height: 300,
} as const;
export const SOFIA_CARTON_EDIT_LINE_ART_LAYOUT = {
left: 5,
top: 114,
width: 223,
height: 150,
} as const;
export const SOFIA_CARTON_MANUFACTURER_SAFE_TOP = 544;
export const SOFIA_CARTON_RIGHT_CONTENT_TOP_OFFSET = -8;
export const SOFIA_CARTON_VALUE_GROUP_TOP_OFFSET = -2;
export const SOFIA_CARTON_TITLE_TEXT_TOP_OFFSET = -6;
export const SOFIA_CARTON_BARCODE_TEXT_TOP_OFFSET = -10;
export const SOFIA_CARTON_VALUE_TEXT_TOP_OFFSET = -9;
export const SOFIA_CARTON_TITLE_LAYOUT = {
productCategory: {
left: 500,
top: 321,
fontSize: 40,
},
barcode: {
left: 500,
top: 391,
fontSize: 40,
},
} as const;
export const SOFIA_CARTON_VALUE_LAYOUT = {
color: {
left: 634,
top: 454,
inputLeft: 314,
inputTop: 229,
inputWidth: 90,
inputHeight: 20,
},
JZ: {
left: 634,
top: 500,
inputLeft: 314,
inputTop: 252,
inputWidth: 90,
inputHeight: 20,
},
MZ: {
left: 952,
top: 500,
inputLeft: 476,
inputTop: 252,
inputWidth: 90,
inputHeight: 20,
},
WXTJ: {
left: 634,
top: 546,
inputLeft: 314,
inputTop: 275,
inputWidth: 90,
inputHeight: 20,
},
WXCC: {
left: 634,
top: 592,
inputLeft: 314,
inputTop: 298,
inputWidth: 90,
inputHeight: 20,
},
XH: {
left: 634,
top: 638,
inputLeft: 314,
inputTop: 321,
inputWidth: 90,
inputHeight: 20,
},
ZXSL: {
left: 634,
top: 684,
inputLeft: 314,
inputTop: 344,
inputWidth: 90,
inputHeight: 20,
},
CD: {
left: 634,
top: 730,
inputLeft: 314,
inputTop: 367,
inputWidth: 90,
inputHeight: 20,
},
} as const;
export type SofiaCartonValueField = keyof typeof SOFIA_CARTON_VALUE_LAYOUT;
export function getSofiaCartonTitleStyle(
key: keyof typeof SOFIA_CARTON_TITLE_LAYOUT,
) {
const field = SOFIA_CARTON_TITLE_LAYOUT[key];
const topOffset =
key === "barcode"
? SOFIA_CARTON_BARCODE_TEXT_TOP_OFFSET
: SOFIA_CARTON_TITLE_TEXT_TOP_OFFSET;
return {
position: "absolute",
left: `${field.left}px`,
top: `${field.top + topOffset + SOFIA_CARTON_RIGHT_CONTENT_TOP_OFFSET}px`,
fontSize: `${field.fontSize}px`,
fontWeight: "bold",
lineHeight: "1",
} as const;
}
export function getSofiaCartonValueStyle(key: SofiaCartonValueField) {
const field = SOFIA_CARTON_VALUE_LAYOUT[key];
return {
position: "absolute",
left: `${field.left}px`,
top: `${field.top + SOFIA_CARTON_VALUE_TEXT_TOP_OFFSET + SOFIA_CARTON_VALUE_GROUP_TOP_OFFSET}px`,
fontSize: "24px",
fontWeight: "bold",
lineHeight: "1",
} as const;
}
export function getSofiaCartonInputStyle(key: SofiaCartonValueField) {
const field = SOFIA_CARTON_VALUE_LAYOUT[key];
return {
position: "absolute",
left: `${field.inputLeft}px`,
top: `${field.inputTop}px`,
width: `${field.inputWidth}px`,
height: `${field.inputHeight}px`,
} as const;
}