Commit 40929347cadf2dac8f0d1dfc7e05f674bfb26440

Authored by 杨鑫
1 parent 2a0e4d1d

feat: update label bulk add and template rotation

美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
... ... @@ -691,7 +691,7 @@ export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLa
691 691 y: Number(e.y ?? e.posY ?? e.PosY ?? 0),
692 692 width: Number(e.width ?? e.Width ?? 0),
693 693 height: Number(e.height ?? e.Height ?? 0),
694   - rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical',
  694 + rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical' | 'left' | 'right' | 'rotate180',
695 695 border: String(e.border ?? e.Border ?? e.BorderType ?? e.borderType ?? 'none'),
696 696 config: cfg as Record<string, any>,
697 697 zIndex: Number(e.zIndex ?? e.ZIndex ?? 0),
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/printInputOffset.ts
... ... @@ -32,6 +32,8 @@ export const DATE_DISPLAY_FORMAT_PRESETS = new Set([
32 32 'YYYY-MM-DD',
33 33 'YYYY-MM-DD HH:mm',
34 34 'HH:mm',
  35 + '12 hr',
  36 + '24 hr',
35 37 ])
36 38  
37 39 export function isKnownDateDisplayFormat (raw: string | null | undefined): boolean {
... ... @@ -372,6 +374,8 @@ function formatDateByPreset (format: string, date: Date): string {
372 374 const dd = String(date.getDate()).padStart(2, '0')
373 375 const hh = String(date.getHours()).padStart(2, '0')
374 376 const min = String(date.getMinutes()).padStart(2, '0')
  377 + const hour12 = date.getHours() % 12 || 12
  378 + const ampm = date.getHours() >= 12 ? 'pm' : 'am'
375 379 const monthLong = monthLongEn(date)
376 380 const dayLong = weekdayLongEn(date)
377 381 const dayShort = weekdayShortEn(date)
... ... @@ -408,6 +412,9 @@ function formatDateByPreset (format: string, date: Date): string {
408 412 return `${yyyy}-${mm}-${dd}`
409 413 case 'YYYY-MM-DD HH:mm':
410 414 return `${yyyy}-${mm}-${dd} ${hh}:${min}`
  415 + case '12 hr':
  416 + return `${hour12}:${min}${ampm}`
  417 + case '24 hr':
411 418 case 'HH:mm':
412 419 return `${hh}:${min}`
413 420 default:
... ... @@ -432,7 +439,7 @@ export function isLikelyResolvedDateTimeLiteral (raw: string | null | undefined)
432 439 if (/^\d{4}-\d{2}-\d{2}/.test(t)) return true
433 440 if (/^\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/.test(t)) return true
434 441 if (/^\d{1,2}\/\d{1,2}\/\d{2,4}/.test(t)) return true
435   - if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(t)) return true
  442 + if (/^\d{1,2}:\d{2}(:\d{2})?(\s?[ap]m)?$/i.test(t)) return true
436 443 return false
437 444 }
438 445  
... ... @@ -462,7 +469,10 @@ function dateFormatPatternForElement (
462 469 cfg: Record<string, unknown>,
463 470 ): string {
464 471 const type = String(el.type || '').toUpperCase()
465   - if (type === 'TIME') return 'HH:mm'
  472 + if (type === 'TIME') {
  473 + const raw = String(cfg.format ?? cfg.Format ?? '24 hr').trim()
  474 + return raw === '12 hr' ? '12 hr' : '24 hr'
  475 + }
466 476  
467 477 /** 仅底部黑条星期走 FULLY DAY;Use By 仍用 MM/DD/YYYY */
468 478 if (isWeekdayBarElement({ ...el, config: cfg })) {
... ... @@ -561,7 +571,7 @@ function resolveFromOffsetAmount (
561 571 return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d))
562 572 }
563 573 if (type === 'TIME') {
564   - return applyElementPrefix(cfg, formatDateByPreset('HH:mm', d))
  574 + return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d))
565 575 }
566 576 return applyElementPrefix(cfg, `${amount} ${unit}`.trim())
567 577 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
... ... @@ -30,6 +30,7 @@ import {
30 30 readFontWeight,
31 31 readTextDecoration,
32 32 readElementRotation,
  33 + elementRotationDegrees,
33 34 isShortSingleLineTextBox,
34 35 type TextVerticalAlign,
35 36 } from '../textElementLayout'
... ... @@ -1233,7 +1234,7 @@ function runLabelPreviewCanvasDraw(
1233 1234 if (!finalText && weekdayBar) {
1234 1235 finalText = resolveWeekdayDisplayForElement(el, baseTime)
1235 1236 }
1236   - const rotation = String(el.rotation ?? (el as any).Rotation ?? 'horizontal').toLowerCase()
  1237 + const rotationDegrees = elementRotationDegrees(el as any)
1237 1238 const drawAt = (bx: number, by: number, bw: number, bh: number) => {
1238 1239 const anyCtx = ctx as any
1239 1240 if (typeof anyCtx.save === 'function') anyCtx.save()
... ... @@ -1353,13 +1354,19 @@ function runLabelPreviewCanvasDraw(
1353 1354 if (typeof anyCtx.restore === 'function') anyCtx.restore()
1354 1355 }
1355 1356  
1356   - if (rotation === 'vertical') {
  1357 + if (rotationDegrees !== 0) {
1357 1358 const anyCtx = ctx as any
1358 1359 if (typeof anyCtx.save === 'function' && typeof anyCtx.rotate === 'function') {
  1360 + const isQuarterTurn = rotationDegrees === -90 || rotationDegrees === 90
1359 1361 anyCtx.save()
1360 1362 anyCtx.translate(x + w / 2, y + h / 2)
1361   - anyCtx.rotate(-Math.PI / 2)
1362   - drawAt(-h / 2, -w / 2, h, w)
  1363 + anyCtx.rotate((rotationDegrees * Math.PI) / 180)
  1364 + drawAt(
  1365 + isQuarterTurn ? -h / 2 : -w / 2,
  1366 + isQuarterTurn ? -w / 2 : -h / 2,
  1367 + isQuarterTurn ? h : w,
  1368 + isQuarterTurn ? w : h,
  1369 + )
1363 1370 anyCtx.restore()
1364 1371 } else {
1365 1372 drawAt(x, y, w, h)
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
... ... @@ -244,7 +244,7 @@ export function templateRequiresCanvasStyleFidelity (template: SystemLabelTempla
244 244 if (border === 'line' || border === 'dotted' || border === 'solid') {
245 245 return true
246 246 }
247   - if (readElementRotation(el) === 'vertical') {
  247 + if (readElementRotation(el) !== 'horizontal') {
248 248 return true
249 249 }
250 250 const cfg = (el.config || {}) as Record<string, unknown>
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
... ... @@ -261,7 +261,11 @@ function toEscAlign (align: SystemTemplateTextAlign): 0 | 1 | 2 {
261 261 }
262 262  
263 263 function resolveRotation (value?: string): number {
264   - return value === 'vertical' ? 90 : 0
  264 + const rotation = String(value || 'horizontal').trim().toLowerCase()
  265 + if (rotation === 'vertical' || rotation === 'left' || rotation === 'rotate-left' || rotation === 'left90') return 90
  266 + if (rotation === 'right' || rotation === 'rotate-right' || rotation === 'right90') return 270
  267 + if (rotation === 'rotate180' || rotation === '180' || rotation === 'down' || rotation === 'inverted') return 180
  268 + return 0
265 269 }
266 270  
267 271 function normalizeQrLevel (value?: string): 'L' | 'M' | 'Q' | 'H' {
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
... ... @@ -68,7 +68,7 @@ export interface RawImageDataSource {
68 68 export type LabelTemplateValue = string | number
69 69 export type LabelTemplateData = Record<string, LabelTemplateValue>
70 70 export type PrinterTemplateUnit = 'inch' | 'mm' | 'cm' | 'px'
71   -export type SystemTemplateRotation = 'horizontal' | 'vertical'
  71 +export type SystemTemplateRotation = 'horizontal' | 'vertical' | 'left' | 'right' | 'rotate180'
72 72 export type SystemTemplatePrintOrientation = 'horizontal' | 'vertical'
73 73 export type SystemTemplateTextAlign = 'left' | 'center' | 'right'
74 74  
... ...
美国版/Food Labeling Management App UniApp/src/utils/textElementLayout.ts
... ... @@ -61,9 +61,22 @@ export function readElementBorder(
61 61 /** 元素 rotation(兼容大小写) */
62 62 export function readElementRotation(
63 63 el: { rotation?: string | null; Rotation?: string | null },
64   -): 'horizontal' | 'vertical' {
  64 +): 'horizontal' | 'vertical' | 'right' | 'rotate180' {
65 65 const v = String(el.rotation ?? el.Rotation ?? 'horizontal').trim().toLowerCase()
66   - return v === 'vertical' ? 'vertical' : 'horizontal'
  66 + if (v === 'vertical' || v === 'left' || v === 'rotate-left' || v === 'left90') return 'vertical'
  67 + if (v === 'right' || v === 'rotate-right' || v === 'right90') return 'right'
  68 + if (v === 'rotate180' || v === '180' || v === 'down' || v === 'inverted') return 'rotate180'
  69 + return 'horizontal'
  70 +}
  71 +
  72 +export function elementRotationDegrees(
  73 + el: { rotation?: string | null; Rotation?: string | null },
  74 +): 0 | -90 | 90 | 180 {
  75 + const rotation = readElementRotation(el)
  76 + if (rotation === 'vertical') return -90
  77 + if (rotation === 'right') return 90
  78 + if (rotation === 'rotate180') return 180
  79 + return 0
67 80 }
68 81  
69 82 /** 与 renderLabelPreviewCanvas lineHeightForTextElement 一致 */
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
... ... @@ -52,6 +52,7 @@ import {
52 52 textAlignToFlexAlign,
53 53 verticalAlignToFlexJustify,
54 54 readElementRotation,
  55 + elementRotationDegrees,
55 56 normalizeElementRotationBox,
56 57 canonicalElementGeometry,
57 58 readFontWeight,
... ... @@ -1146,6 +1147,8 @@ function formatDateByPreset(format: string, date: Date): string {
1146 1147 const dd = String(date.getDate()).padStart(2, '0');
1147 1148 const hh = String(date.getHours()).padStart(2, '0');
1148 1149 const min = String(date.getMinutes()).padStart(2, '0');
  1150 + const hour12 = date.getHours() % 12 || 12;
  1151 + const ampm = date.getHours() >= 12 ? 'pm' : 'am';
1149 1152 const monthLong = date.toLocaleString('en-US', { month: 'long' }).toUpperCase();
1150 1153 const dayLong = date.toLocaleString('en-US', { weekday: 'long' }).toUpperCase();
1151 1154 const dayShort = date.toLocaleString('en-US', { weekday: 'short' }).toUpperCase();
... ... @@ -1179,6 +1182,11 @@ function formatDateByPreset(format: string, date: Date): string {
1179 1182 return yyyy;
1180 1183 case 'DD MONTH YEAR (25 DECEMBER 2025)':
1181 1184 return `${dd} ${monthLong} ${yyyy}`;
  1185 + case '12 hr':
  1186 + return `${hour12}:${min}${ampm}`;
  1187 + case '24 hr':
  1188 + case 'HH:mm':
  1189 + return `${hh}:${min}`;
1182 1190 default:
1183 1191 return format
1184 1192 .replace('YYYY', yyyy)
... ... @@ -1216,14 +1224,17 @@ function VerticalRotationFrame({
1216 1224 className,
1217 1225 boxWidth,
1218 1226 boxHeight,
  1227 + rotationDegrees,
1219 1228 }: {
1220 1229 children: React.ReactNode;
1221 1230 className?: string;
1222 1231 boxWidth: number;
1223 1232 boxHeight: number;
  1233 + rotationDegrees: 0 | -90 | 90 | 180;
1224 1234 }) {
1225   - const innerW = Math.max(1, boxHeight);
1226   - const innerH = Math.max(1, boxWidth);
  1235 + const isQuarterTurn = rotationDegrees === -90 || rotationDegrees === 90;
  1236 + const innerW = Math.max(1, isQuarterTurn ? boxHeight : boxWidth);
  1237 + const innerH = Math.max(1, isQuarterTurn ? boxWidth : boxHeight);
1227 1238 return (
1228 1239 <div className={cn('relative flex h-full w-full items-center justify-center overflow-visible', className)}>
1229 1240 <div
... ... @@ -1231,7 +1242,7 @@ function VerticalRotationFrame({
1231 1242 style={{
1232 1243 width: innerW,
1233 1244 height: innerH,
1234   - transform: 'rotate(-90deg)',
  1245 + transform: `rotate(${rotationDegrees}deg)`,
1235 1246 transformOrigin: 'center center',
1236 1247 }}
1237 1248 >
... ... @@ -1334,7 +1345,8 @@ function ElementContent({
1334 1345 }) {
1335 1346 const cfg = el.config as Record<string, unknown>;
1336 1347 const type = canonicalElementType(el.type);
1337   - const isVerticalRotation = readElementRotation(el) === 'vertical';
  1348 + const rotationDegrees = elementRotationDegrees(el);
  1349 + const isVerticalRotation = rotationDegrees !== 0;
1338 1350  
1339 1351 const wrapVertical = (node: React.ReactNode, className?: string) =>
1340 1352 isVerticalRotation ? (
... ... @@ -1342,6 +1354,7 @@ function ElementContent({
1342 1354 className={className}
1343 1355 boxWidth={el.width}
1344 1356 boxHeight={el.height}
  1357 + rotationDegrees={rotationDegrees}
1345 1358 >
1346 1359 {node}
1347 1360 </VerticalRotationFrame>
... ... @@ -1693,7 +1706,9 @@ function ElementContent({
1693 1706 );
1694 1707 }
1695 1708 const d = new Date();
1696   - const example = formatDateByPreset('HH:mm', d);
  1709 + const timeFormatRaw = String(cfg?.format ?? cfg?.Format ?? '24 hr').trim();
  1710 + const timeFormat = timeFormatRaw === '12 hr' ? '12 hr' : '24 hr';
  1711 + const example = formatDateByPreset(timeFormat, d);
1697 1712 return wrapVertical(
1698 1713 <AlignedTextBox cfg={cfg} commonStyle={commonStyle} className="whitespace-nowrap">
1699 1714 {example}
... ... @@ -1782,9 +1797,9 @@ function ElementContent({
1782 1797 style={
1783 1798 isVerticalRotation
1784 1799 ? {
1785   - width: el.height,
1786   - height: el.width,
1787   - transform: 'rotate(-90deg)',
  1800 + width: rotationDegrees === -90 || rotationDegrees === 90 ? el.height : el.width,
  1801 + height: rotationDegrees === -90 || rotationDegrees === 90 ? el.width : el.height,
  1802 + transform: `rotate(${rotationDegrees}deg)`,
1788 1803 transformOrigin: 'center center',
1789 1804 }
1790 1805 : { width: '100%', height: '100%' }
... ... @@ -3117,7 +3132,7 @@ export function LabelCanvas({
3117 3132 const isPrintField = isPrintInputElement(el);
3118 3133 const isSelected = selectedId === el.id;
3119 3134 const positionLocked = readElementPositionLocked(el);
3120   - const isVerticalRotation = readElementRotation(effectiveEl) === 'vertical';
  3135 + const isVerticalRotation = elementRotationDegrees(effectiveEl) !== 0;
3121 3136 return (
3122 3137 <div
3123 3138 key={el.id}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
1 1 import React, { useCallback, useEffect, useState } from 'react';
2   -import { Building2, Check, Mail, Map, MapPin, Mailbox, Trash2 } from 'lucide-react';
  2 +import { Building2, Check, Mail, Map, MapPin, Mailbox, RotateCcw, RotateCw, Trash2 } from 'lucide-react';
3 3 import { Input } from '../../ui/input';
4 4 import { Button } from '../../ui/button';
5 5 import { Label } from '../../ui/label';
... ... @@ -42,7 +42,15 @@ import {
42 42 } from '../../../utils/companyPrintFields';
43 43 import { cn } from '../../ui/utils';
44 44 import { readInvertColors, isTextLikeElementForInvertColors } from '../../../utils/invertColorsConfig';
45   -import { readVerticalAlign, readElementRotation, readFontWeight, readFontStyle, readTextDecoration } from '../../../utils/textElementLayout';
  45 +import {
  46 + elementRotationDegrees,
  47 + readVerticalAlign,
  48 + rotateElementLeftValue,
  49 + rotateElementRightValue,
  50 + readFontWeight,
  51 + readFontStyle,
  52 + readTextDecoration,
  53 +} from '../../../utils/textElementLayout';
46 54 import {
47 55 type PreviewRulerDisplayUnit,
48 56 displayLengthToElementPx,
... ... @@ -209,23 +217,42 @@ export function PropertiesPanel({
209 217 ) : null}
210 218 {!isBlankElement ? (
211 219 <div>
212   - <Label className="text-xs">Rotation</Label>
213   - <Select
214   - value={readElementRotation(selectedElement)}
215   - onValueChange={(v: Rotation) =>
216   - onElementChange(selectedElement.id, {
217   - rotation: v,
218   - })
219   - }
220   - >
221   - <SelectTrigger className="h-8 text-sm">
222   - <SelectValue />
223   - </SelectTrigger>
224   - <SelectContent>
225   - <SelectItem value="horizontal">horizontal</SelectItem>
226   - <SelectItem value="vertical">vertical</SelectItem>
227   - </SelectContent>
228   - </Select>
  220 + <Label className="text-xs">Rotate</Label>
  221 + <div className="mt-1 flex items-center gap-2">
  222 + <Button
  223 + type="button"
  224 + variant="outline"
  225 + size="sm"
  226 + className="h-8 gap-1 px-2 text-xs"
  227 + disabled={positionLocked}
  228 + onClick={() =>
  229 + onElementChange(selectedElement.id, {
  230 + rotation: rotateElementLeftValue(selectedElement) as Rotation,
  231 + })
  232 + }
  233 + >
  234 + <RotateCcw className="h-4 w-4" />
  235 + Left
  236 + </Button>
  237 + <Button
  238 + type="button"
  239 + variant="outline"
  240 + size="sm"
  241 + className="h-8 gap-1 px-2 text-xs"
  242 + disabled={positionLocked}
  243 + onClick={() =>
  244 + onElementChange(selectedElement.id, {
  245 + rotation: rotateElementRightValue(selectedElement) as Rotation,
  246 + })
  247 + }
  248 + >
  249 + <RotateCw className="h-4 w-4" />
  250 + Right
  251 + </Button>
  252 + <span className="text-xs text-gray-500">
  253 + {elementRotationDegrees(selectedElement)}&deg;
  254 + </span>
  255 + </div>
229 256 </div>
230 257 ) : null}
231 258 {!isBlankElement ? (
... ... @@ -1194,11 +1221,20 @@ function ElementConfigFields({
1194 1221 );
1195 1222 }
1196 1223 case 'TIME':
  1224 + const timeFormat = cfgPickStr(cfg, ['format', 'Format'], '24 hr');
1197 1225 return (
1198 1226 <>
1199 1227 <div>
1200   - <Label className="text-xs">Format</Label>
1201   - <Input value="HH:mm" className="h-8 text-sm mt-1" readOnly />
  1228 + <Label className="text-xs">Time Format</Label>
  1229 + <Select value={timeFormat === '12 hr' ? '12 hr' : '24 hr'} onValueChange={(v) => update('format', v)}>
  1230 + <SelectTrigger className="h-8 text-sm mt-1">
  1231 + <SelectValue />
  1232 + </SelectTrigger>
  1233 + <SelectContent>
  1234 + <SelectItem value="12 hr">12 hr - 2:00pm</SelectItem>
  1235 + <SelectItem value="24 hr">24 hr - 14:00</SelectItem>
  1236 + </SelectContent>
  1237 + </Select>
1202 1238 </div>
1203 1239 <FontFamilyField cfg={cfg} update={update} />
1204 1240 <div>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
... ... @@ -438,7 +438,8 @@ export function LabelTemplateEditor({
438 438 patch.rotation !== undefined;
439 439 if (!geomTouched) return merged;
440 440 // 竖排且宽>高:先纠正宽高再 clamp,避免 x 被钳到最左边
441   - if (readElementRotation(merged) === 'vertical' && merged.width > merged.height) {
  441 + const mergedRotation = readElementRotation(merged);
  442 + if ((mergedRotation === 'vertical' || mergedRotation === 'right') && merged.width > merged.height) {
442 443 merged = canonicalElementGeometry(merged);
443 444 }
444 445 const c = clampLabelElementBox(
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
... ... @@ -42,8 +42,8 @@ import {
42 42 PaginationNext,
43 43 PaginationPrevious,
44 44 } from "../ui/pagination";
45   -import { getLabels, getLabel, createLabel, updateLabel, deleteLabel } from "../../services/labelService";
46   -import type { LabelDto, LabelCreateInput, LabelUpdateInput } from "../../types/label";
  45 +import { getLabels, getLabel, createLabel, batchCreateLabels, updateLabel, deleteLabel } from "../../services/labelService";
  46 +import type { LabelDto, LabelCreateInput, LabelBatchCreateItemInput, LabelUpdateInput } from "../../types/label";
47 47 import { getLocations } from "../../services/locationService";
48 48 import { getGroups } from "../../services/groupService";
49 49 import { getPartners } from "../../services/partnerService";
... ... @@ -2031,7 +2031,7 @@ function LabelBulkAddPage({
2031 2031 const removeRow = (rowId: string) =>
2032 2032 setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.id !== rowId)));
2033 2033  
2034   - const validateAndBuildInput = (row: BulkAddRow, index: number): LabelCreateInput | null => {
  2034 + const validateAndBuildInput = (row: BulkAddRow, index: number): LabelBatchCreateItemInput | null => {
2035 2035 const rowName = `Row ${index + 1}`;
2036 2036 const effectivePartnerId = effectiveScopePartnerId(
2037 2037 scopeAuth.requireCompanySelection,
... ... @@ -2092,18 +2092,42 @@ function LabelBulkAddPage({
2092 2092 });
2093 2093 return null;
2094 2094 }
  2095 + const productId = row.productId.trim();
  2096 + const labelTypeId = row.labelTypeId.trim();
  2097 + const product = products.find((x) => x.id === productId);
  2098 + const labelType = types.find((x) => x.id === labelTypeId);
  2099 + const productCodeValue = (product?.codeValue ?? "").trim();
  2100 + const templateDefaultValues = selectedTemplate
  2101 + ? collectTemplateDefaultValuesForSave(
  2102 + selectedTemplate,
  2103 + row.templateDataValues,
  2104 + row.templateDateOffsets,
  2105 + row.nutritionByElementId,
  2106 + productCodeValue,
  2107 + {
  2108 + labelName: row.labelName,
  2109 + labelTypeName: labelType?.typeName ?? labelType?.typeCode,
  2110 + },
  2111 + )
  2112 + : {};
2095 2113 return {
2096 2114 labelName: row.labelName.trim(),
2097   - templateCode,
2098 2115 locationIds: locPayload.locationIds,
  2116 + locationId: locPayload.locationIds[0] ?? null,
2099 2117 labelCategoryId: row.labelCategoryId.trim(),
2100   - labelTypeId: row.labelTypeId.trim(),
2101   - productIds: [row.productId.trim()],
  2118 + labelTypeId,
  2119 + productIds: [productId],
2102 2120 labelInfoJson: null,
2103 2121 state: row.state,
2104 2122 appliedRegionType: regionPayload.appliedRegionType,
2105 2123 regionIds: regionPayload.regionIds,
2106 2124 groupIds: regionPayload.regionIds,
  2125 + partnerId: effectivePartnerId,
  2126 + partnerIds: effectivePartnerId ? [effectivePartnerId] : [],
  2127 + templateDefaultValues,
  2128 + templateDataValues: { ...row.templateDataValues },
  2129 + templateDateOffsets: { ...row.templateDateOffsets },
  2130 + nutritionByElementId: { ...row.nutritionByElementId },
2107 2131 };
2108 2132 };
2109 2133  
... ... @@ -2168,31 +2192,46 @@ function LabelBulkAddPage({
2168 2192 };
2169 2193  
2170 2194 const submit = async () => {
2171   - const inputs: LabelCreateInput[] = [];
2172   - const validRows: BulkAddRow[] = [];
  2195 + const inputs: LabelBatchCreateItemInput[] = [];
  2196 + if (rows.length > 500) {
  2197 + toast.error("Too many rows.", {
  2198 + description: "Bulk Add supports up to 500 rows at a time.",
  2199 + });
  2200 + return;
  2201 + }
2173 2202 for (let i = 0; i < rows.length; i += 1) {
2174 2203 const input = validateAndBuildInput(rows[i], i);
2175 2204 if (!input) return;
2176 2205 inputs.push(input);
2177   - validRows.push(rows[i]);
2178 2206 }
2179 2207  
2180 2208 setSaving(true);
2181 2209 try {
2182   - for (const input of inputs) {
2183   - await createLabel(input);
2184   - }
2185   - try {
2186   - await saveTemplateDefaultsAfterLabels(validRows);
2187   - } catch (e: any) {
2188   - toast.warning("Labels created, template data failed.", {
2189   - description: e?.message ? String(e.message) : "Please edit template data manually.",
  2210 + const result = await batchCreateLabels({
  2211 + templateCode: templateCode.trim(),
  2212 + saveTemplateProductDefaults: true,
  2213 + items: inputs,
  2214 + });
  2215 + const successCount = Number(result.successCount ?? 0);
  2216 + const failCount = Number(result.failCount ?? 0);
  2217 + if (failCount > 0) {
  2218 + const firstErrors = (result.errors ?? [])
  2219 + .slice(0, 3)
  2220 + .map((err) => {
  2221 + const rowNo = err.rowNumber ? `Row ${err.rowNumber}` : "Row";
  2222 + return `${rowNo}: ${err.message || "Failed"}`;
  2223 + })
  2224 + .join("; ");
  2225 + toast.warning("Bulk Add completed with errors.", {
  2226 + description: `${successCount} created, ${failCount} failed.${firstErrors ? ` ${firstErrors}` : ""}`,
  2227 + });
  2228 + if (successCount > 0) onBack();
  2229 + } else {
  2230 + toast.success("Labels created.", {
  2231 + description: `${successCount || inputs.length} label(s) have been created successfully.`,
2190 2232 });
  2233 + onBack();
2191 2234 }
2192   - toast.success("Labels created.", {
2193   - description: `${inputs.length} label(s) have been created successfully.`,
2194   - });
2195   - onBack();
2196 2235 } catch (e: any) {
2197 2236 toast.error("Failed to create labels.", {
2198 2237 description: e?.message ? String(e.message) : "Please check the rows and try again.",
... ...
美国版/Food Labeling Management Platform/src/lib/labelFormDatePreview.ts
... ... @@ -58,6 +58,8 @@ export function formatDateByPreset(format: string, date: Date): string {
58 58 const dd = String(date.getDate()).padStart(2, "0");
59 59 const hh = String(date.getHours()).padStart(2, "0");
60 60 const min = String(date.getMinutes()).padStart(2, "0");
  61 + const hour12 = date.getHours() % 12 || 12;
  62 + const ampm = date.getHours() >= 12 ? "pm" : "am";
61 63 const monthLong = date.toLocaleString("en-US", { month: "long" }).toUpperCase();
62 64 const dayLong = date.toLocaleString("en-US", { weekday: "long" }).toUpperCase();
63 65 const dayShort = date.toLocaleString("en-US", { weekday: "short" }).toUpperCase();
... ... @@ -91,6 +93,11 @@ export function formatDateByPreset(format: string, date: Date): string {
91 93 return yyyy;
92 94 case "DD MONTH YEAR (25 DECEMBER 2025)":
93 95 return `${dd} ${monthLong} ${yyyy}`;
  96 + case "12 hr":
  97 + return `${hour12}:${min}${ampm}`;
  98 + case "24 hr":
  99 + case "HH:mm":
  100 + return `${hh}:${min}`;
94 101 default:
95 102 return format
96 103 .replace(/YYYY/g, yyyy)
... ... @@ -112,7 +119,7 @@ export function isLikelyResolvedDateTimeLiteral(raw: string | null | undefined):
112 119 if (/^\d{4}-\d{2}-\d{2}/.test(t)) return true;
113 120 if (/^\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/.test(t)) return true;
114 121 if (/^\d{1,2}\/\d{1,2}\/\d{2,4}/.test(t)) return true;
115   - if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(t)) return true;
  122 + if (/^\d{1,2}:\d{2}(:\d{2})?(\s?[ap]m)?$/i.test(t)) return true;
116 123 return false;
117 124 }
118 125  
... ... @@ -206,7 +213,7 @@ export function resolveStoredPrintValueToDisplayText(
206 213 : format;
207 214 return formatDateByPreset(fmt, d);
208 215 }
209   - if (type === "TIME") return formatDateByPreset("HH:mm", d);
  216 + if (type === "TIME") return formatDateByPreset(dateFormatPatternForElement(el, cfg), d);
210 217 }
211 218 return s;
212 219 }
... ... @@ -230,7 +237,7 @@ export function resolveStoredPrintValueToDisplayText(
230 237 return formatDateByPreset(format, d);
231 238 }
232 239 if (type === "TIME") {
233   - return formatDateByPreset("HH:mm", d);
  240 + return formatDateByPreset(dateFormatPatternForElement(el, cfg), d);
234 241 }
235 242 return `${amount} ${unit}`;
236 243 }
... ... @@ -244,7 +251,10 @@ function applyElementPrefix(cfg: Record&lt;string, unknown&gt;, body: string): string
244 251  
245 252 function dateFormatPatternForElement(el: LabelElement, cfg: Record<string, unknown>): string {
246 253 const type = canonicalElementType(el.type);
247   - if (type === "TIME") return "HH:mm";
  254 + if (type === "TIME") {
  255 + const raw = String(cfg.format ?? cfg.Format ?? "24 hr").trim();
  256 + return raw === "12 hr" ? "12 hr" : "24 hr";
  257 + }
248 258 const it = String(cfg.inputType ?? cfg.InputType ?? "").toLowerCase();
249 259 const raw =
250 260 (typeof cfg.format === "string" && cfg.format.trim()
... ... @@ -303,7 +313,7 @@ function resolveFromOffsetAmount(
303 313 return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d));
304 314 }
305 315 if (type === "TIME") {
306   - return applyElementPrefix(cfg, formatDateByPreset("HH:mm", d));
  316 + return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d));
307 317 }
308 318 return applyElementPrefix(cfg, `${amount} ${unit}`.trim());
309 319 }
... ... @@ -415,4 +425,3 @@ export function applyLiveDateTimePreviewToElements(
415 425 return { ...el, config: cfg };
416 426 });
417 427 }
418   -
... ...
美国版/Food Labeling Management Platform/src/services/labelService.ts
1 1 import { createApiClient } from "../lib/apiClient";
2 2 import type {
3 3 LabelCreateInput,
  4 + LabelBatchCreateInput,
  5 + LabelBatchCreateResult,
4 6 LabelDto,
5 7 LabelGetListInput,
6 8 LabelUpdateInput,
... ... @@ -156,6 +158,92 @@ export async function createLabel(input: LabelCreateInput): Promise&lt;LabelDto&gt; {
156 158 return normalizeLabelDto(raw);
157 159 }
158 160  
  161 +function normalizeBatchCreateResult(raw: unknown): LabelBatchCreateResult {
  162 + const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
  163 + const successItemsRaw = r.successItems ?? r.SuccessItems;
  164 + const errorsRaw = r.errors ?? r.Errors;
  165 + const successItems = Array.isArray(successItemsRaw)
  166 + ? successItemsRaw.map((x) => {
  167 + const it = (x && typeof x === "object" ? x : {}) as Record<string, unknown>;
  168 + return {
  169 + rowNumber: typeof (it.rowNumber ?? it.RowNumber) === "number" ? (it.rowNumber ?? it.RowNumber) as number : null,
  170 + labelCode: it.labelCode != null || it.LabelCode != null ? String(it.labelCode ?? it.LabelCode) : null,
  171 + labelName: it.labelName != null || it.LabelName != null ? String(it.labelName ?? it.LabelName) : null,
  172 + };
  173 + })
  174 + : [];
  175 + const errors = Array.isArray(errorsRaw)
  176 + ? errorsRaw.map((x) => {
  177 + const it = (x && typeof x === "object" ? x : {}) as Record<string, unknown>;
  178 + return {
  179 + rowNumber: typeof (it.rowNumber ?? it.RowNumber) === "number" ? (it.rowNumber ?? it.RowNumber) as number : null,
  180 + labelName: it.labelName != null || it.LabelName != null ? String(it.labelName ?? it.LabelName) : null,
  181 + message: it.message != null || it.Message != null ? String(it.message ?? it.Message) : null,
  182 + };
  183 + })
  184 + : [];
  185 + return {
  186 + successCount: Number(r.successCount ?? r.SuccessCount ?? successItems.length) || 0,
  187 + failCount: Number(r.failCount ?? r.FailCount ?? errors.length) || 0,
  188 + successItems,
  189 + errors,
  190 + };
  191 +}
  192 +
  193 +export async function batchCreateLabels(input: LabelBatchCreateInput): Promise<LabelBatchCreateResult> {
  194 + const raw = await api.requestJson<unknown>({
  195 + path: `${PATH}/batch-create`,
  196 + method: "POST",
  197 + body: {
  198 + templateCode: input.templateCode,
  199 + saveTemplateProductDefaults: input.saveTemplateProductDefaults ?? true,
  200 + items: input.items.map((item) => {
  201 + const locationIds = [
  202 + ...new Set((item.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean)),
  203 + ];
  204 + const regionIds = [
  205 + ...new Set(
  206 + [...(item.regionIds ?? []), ...(item.groupIds ?? [])]
  207 + .map((x) => String(x).trim())
  208 + .filter(Boolean),
  209 + ),
  210 + ];
  211 + const partnerIds = [
  212 + ...new Set(
  213 + [
  214 + ...(item.partnerId ? [item.partnerId] : []),
  215 + ...(item.partnerIds ?? []),
  216 + ]
  217 + .map((x) => String(x).trim())
  218 + .filter(Boolean),
  219 + ),
  220 + ];
  221 + return {
  222 + labelCode: String(item.labelCode ?? "").trim() || null,
  223 + labelName: item.labelName,
  224 + labelCategoryId: item.labelCategoryId,
  225 + labelTypeId: String(item.labelTypeId ?? "").trim() || null,
  226 + productIds: item.productIds,
  227 + locationIds,
  228 + locationId: item.locationId ?? locationIds[0] ?? null,
  229 + appliedRegionType: item.appliedRegionType,
  230 + regionIds,
  231 + groupIds: regionIds,
  232 + partnerId: item.partnerId ?? partnerIds[0] ?? null,
  233 + partnerIds,
  234 + labelInfoJson: item.labelInfoJson ?? null,
  235 + state: item.state ?? true,
  236 + templateDefaultValues: item.templateDefaultValues ?? {},
  237 + templateDataValues: item.templateDataValues ?? {},
  238 + templateDateOffsets: item.templateDateOffsets ?? {},
  239 + nutritionByElementId: item.nutritionByElementId ?? {},
  240 + };
  241 + }),
  242 + },
  243 + });
  244 + return normalizeBatchCreateResult(raw);
  245 +}
  246 +
159 247 export async function updateLabel(labelCode: string, input: LabelUpdateInput): Promise<LabelDto> {
160 248 const raw = await api.requestJson<unknown>({
161 249 path: `${PATH}/${encodeURIComponent(labelCode)}`,
... ...
美国版/Food Labeling Management Platform/src/types/label.ts
... ... @@ -75,6 +75,57 @@ export type LabelCreateInput = {
75 75 groupIds?: string[] | null;
76 76 };
77 77  
  78 +export type LabelBatchDateOffsetInput = {
  79 + unit: string;
  80 + value: string;
  81 +};
  82 +
  83 +export type LabelBatchCreateItemInput = {
  84 + labelCode?: string | null;
  85 + labelName: string;
  86 + labelCategoryId: string;
  87 + labelTypeId?: string | null;
  88 + productIds: string[];
  89 + locationIds?: string[] | null;
  90 + locationId?: string | null;
  91 + appliedRegionType?: "ALL" | "SPECIFIED" | string | null;
  92 + regionIds?: string[] | null;
  93 + groupIds?: string[] | null;
  94 + partnerId?: string | null;
  95 + partnerIds?: string[] | null;
  96 + labelInfoJson?: Record<string, unknown> | null;
  97 + state?: boolean;
  98 + templateDefaultValues?: Record<string, unknown>;
  99 + templateDataValues?: Record<string, string>;
  100 + templateDateOffsets?: Record<string, LabelBatchDateOffsetInput>;
  101 + nutritionByElementId?: Record<string, Record<string, string>>;
  102 +};
  103 +
  104 +export type LabelBatchCreateInput = {
  105 + templateCode: string;
  106 + saveTemplateProductDefaults?: boolean;
  107 + items: LabelBatchCreateItemInput[];
  108 +};
  109 +
  110 +export type LabelBatchCreateSuccessItem = {
  111 + rowNumber?: number | null;
  112 + labelCode?: string | null;
  113 + labelName?: string | null;
  114 +};
  115 +
  116 +export type LabelBatchCreateErrorItem = {
  117 + rowNumber?: number | null;
  118 + labelName?: string | null;
  119 + message?: string | null;
  120 +};
  121 +
  122 +export type LabelBatchCreateResult = {
  123 + successCount: number;
  124 + failCount: number;
  125 + successItems: LabelBatchCreateSuccessItem[];
  126 + errors: LabelBatchCreateErrorItem[];
  127 +};
  128 +
78 129 export type LabelUpdateInput = {
79 130 labelName: string;
80 131 templateCode: string;
... ...
美国版/Food Labeling Management Platform/src/types/labelTemplate.ts
... ... @@ -8,7 +8,7 @@ export type LabelType = &#39;PRICE&#39; | &#39;NUTRITION&#39; | &#39;SHIPPING&#39;;
8 8 export type Unit = 'cm' | 'inch';
9 9 /** 与接口文档一致:ALL=全部门店,SPECIFIED=指定门店(需 appliedLocationIds) */
10 10 export type AppliedLocation = 'ALL' | 'SPECIFIED';
11   -export type Rotation = 'horizontal' | 'vertical';
  11 +export type Rotation = 'horizontal' | 'vertical' | 'left' | 'right' | 'rotate180';
12 12  
13 13 /** 模板打印方向:vertical=竖打(默认,内容不旋转);horizontal=横打(纸张 W×H 不变,内容整体旋转 90°) */
14 14 export type PrintOrientation = 'horizontal' | 'vertical';
... ... @@ -659,7 +659,7 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl
659 659 height: 24,
660 660 config: { format: 'DD/MM/YYYY', offsetDays: 0, fontFamily: editorFont, fontSize: 14, textAlign: 'left' },
661 661 },
662   - TIME: { width: 100, height: 24, config: { format: 'HH:mm', offsetDays: 0, fontFamily: editorFont, fontSize: 14, textAlign: 'left' } },
  662 + TIME: { width: 100, height: 24, config: { format: '24 hr', offsetDays: 0, fontFamily: editorFont, fontSize: 14, textAlign: 'left' } },
663 663 DURATION: {
664 664 width: 120,
665 665 height: 24,
... ...
美国版/Food Labeling Management Platform/src/utils/textElementLayout.ts
... ... @@ -75,9 +75,44 @@ export function readElementBorder(
75 75 /** 读取元素 rotation(兼容大小写 / 旧数据) */
76 76 export function readElementRotation(
77 77 el: { rotation?: string | null },
78   -): 'horizontal' | 'vertical' {
  78 +): 'horizontal' | 'vertical' | 'right' | 'rotate180' {
79 79 const v = String(el.rotation ?? 'horizontal').trim().toLowerCase();
80   - return v === 'vertical' ? 'vertical' : 'horizontal';
  80 + if (v === 'vertical' || v === 'left' || v === 'rotate-left' || v === 'left90') return 'vertical';
  81 + if (v === 'right' || v === 'rotate-right' || v === 'right90') return 'right';
  82 + if (v === 'rotate180' || v === '180' || v === 'down' || v === 'inverted') return 'rotate180';
  83 + return 'horizontal';
  84 +}
  85 +
  86 +export function elementRotationDegrees(
  87 + el: { rotation?: string | null },
  88 +): 0 | -90 | 90 | 180 {
  89 + const rot = readElementRotation(el);
  90 + if (rot === 'vertical') return -90;
  91 + if (rot === 'right') return 90;
  92 + if (rot === 'rotate180') return 180;
  93 + return 0;
  94 +}
  95 +
  96 +export function rotateElementLeftValue(
  97 + el: { rotation?: string | null },
  98 +): 'horizontal' | 'vertical' | 'right' | 'rotate180' {
  99 + const current = elementRotationDegrees(el);
  100 + const next = (((current - 90) % 360) + 360) % 360;
  101 + if (next === 270) return 'vertical';
  102 + if (next === 180) return 'rotate180';
  103 + if (next === 90) return 'right';
  104 + return 'horizontal';
  105 +}
  106 +
  107 +export function rotateElementRightValue(
  108 + el: { rotation?: string | null },
  109 +): 'horizontal' | 'vertical' | 'right' | 'rotate180' {
  110 + const current = elementRotationDegrees(el);
  111 + const next = (((current + 90) % 360) + 360) % 360;
  112 + if (next === 270) return 'vertical';
  113 + if (next === 180) return 'rotate180';
  114 + if (next === 90) return 'right';
  115 + return 'horizontal';
81 116 }
82 117  
83 118 /** 与 App lineHeightForTextElement 一致:leading-tight ≈ 1.25 */
... ... @@ -124,18 +159,19 @@ export function fitFontSizeToInnerWidthEstimate(
124 159 /** 切换 horizontal / vertical 时交换宽高并保持中心点不变(与参考图 2 一致) */
125 160 export function patchElementRotationWithLayout(
126 161 el: { x: number; y: number; width: number; height: number; rotation?: string | null },
127   - nextRotation: 'horizontal' | 'vertical',
  162 + nextRotation: 'horizontal' | 'vertical' | 'right' | 'rotate180',
128 163 ): {
129   - rotation: 'horizontal' | 'vertical';
  164 + rotation: 'horizontal' | 'vertical' | 'right' | 'rotate180';
130 165 x: number;
131 166 y: number;
132 167 width: number;
133 168 height: number;
134 169 } {
135 170 const prev = readElementRotation(el);
  171 + const shouldSwapBox = (rotation: string) => rotation === 'vertical' || rotation === 'right';
136 172 if (prev === nextRotation) {
137 173 // vertical 但宽>高:选框与文字方向不一致,强制纠正
138   - if (nextRotation === 'vertical' && el.width > el.height) {
  174 + if (shouldSwapBox(nextRotation) && el.width > el.height) {
139 175 const w = Math.max(1, el.width);
140 176 const h = Math.max(1, el.height);
141 177 const cx = el.x + w / 2;
... ... @@ -158,6 +194,15 @@ export function patchElementRotationWithLayout(
158 194 height: el.height,
159 195 };
160 196 }
  197 + if (!shouldSwapBox(prev) && !shouldSwapBox(nextRotation)) {
  198 + return {
  199 + rotation: nextRotation,
  200 + x: el.x,
  201 + y: el.y,
  202 + width: el.width,
  203 + height: el.height,
  204 + };
  205 + }
161 206 const w = Math.max(1, el.width);
162 207 const h = Math.max(1, el.height);
163 208 const cx = el.x + w / 2;
... ... @@ -184,11 +229,12 @@ export function canonicalElementGeometry&lt;
184 229 export function normalizeElementRotationBox<
185 230 T extends { rotation?: string | null; width: number; height: number; x: number; y: number },
186 231 >(el: T): T {
187   - if (readElementRotation(el) !== 'vertical') return el;
  232 + const rotation = readElementRotation(el);
  233 + if (rotation !== 'vertical' && rotation !== 'right') return el;
188 234 if (el.width <= el.height) return el;
189 235 return {
190 236 ...el,
191   - rotation: 'vertical',
192   - ...patchElementRotationWithLayout({ ...el, rotation: 'horizontal' }, 'vertical'),
  237 + rotation,
  238 + ...patchElementRotationWithLayout({ ...el, rotation: 'horizontal' }, rotation),
193 239 };
194 240 }
... ...