Commit 40929347cadf2dac8f0d1dfc7e05f674bfb26440
1 parent
2a0e4d1d
feat: update label bulk add and template rotation
Showing
16 changed files
with
400 additions
and
81 deletions
美国版/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)}° | |
| 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<string, unknown>, 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<LabelDto> { |
| 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 = 'PRICE' | 'NUTRITION' | 'SHIPPING'; |
| 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< |
| 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 | } | ... | ... |