Commit 9d21893049b40f11372c0a879399537b164c131b

Authored by 杨鑫
1 parent b11f42ef

修改

Showing 19 changed files with 562 additions and 87 deletions
打印机安卓基座/native-fast-printer/android-src/src/com/foodlabel/nativeprinter/template/NativeTemplateCommandBuilder.java
... ... @@ -367,6 +367,9 @@ public final class NativeTemplateCommandBuilder {
367 367 }
368 368  
369 369 private static boolean shouldRasterizeText(String text, String type, JSONObject config) {
  370 + if (getBoolean(config, "invertColors", false) || getBoolean(config, "InvertColors", false)) {
  371 + return true;
  372 + }
370 373 if (getBoolean(config, "forceRasterText", false)) {
371 374 return true;
372 375 }
... ... @@ -431,11 +434,13 @@ public final class NativeTemplateCommandBuilder {
431 434 text = text.replaceAll("\\s{2,}", " ").trim();
432 435 }
433 436 int contentWidth = Math.max(8, pxToDots(getDouble(element, "width", 0), dpi));
  437 + int elementHeightDots = Math.max(16, pxToDots(getDouble(element, "height", 0), dpi));
  438 + boolean inverted = getBoolean(config, "invertColors", false) || getBoolean(config, "InvertColors", false);
434 439 Paint paint = new Paint();
435 440 paint.setAntiAlias(true);
436 441 paint.setDither(true);
437 442 paint.setSubpixelText(true);
438   - paint.setColor(Color.BLACK);
  443 + paint.setColor(inverted ? Color.WHITE : Color.BLACK);
439 444 int fontSizeDots = Math.max(14, pxToDots(getDouble(config, "fontSize", 14), dpi));
440 445 paint.setTextSize(fontSizeDots);
441 446 /** 不再对 TEXT_PRICE 强制加粗:fakeBold + 粗体会糊边、measureText 偏窄,右对齐时左侧易出现杂点 */
... ... @@ -467,10 +472,15 @@ public final class NativeTemplateCommandBuilder {
467 472 int horizontalPadding = TEXT_PADDING_DOTS * 2;
468 473 int verticalPadding = TEXT_PADDING_DOTS * 2;
469 474 int width = ensureMultipleOf8(Math.max(contentWidth + horizontalPadding * 2, (int) Math.ceil(maxLineWidth) + horizontalPadding * 2 + 4));
470   - int height = Math.max(16, Math.max(pxToDots(getDouble(element, "height", 0), dpi) + verticalPadding * 2, totalHeight + verticalPadding * 2 + 4));
  475 + int height = Math.max(16, Math.max(elementHeightDots + verticalPadding * 2, totalHeight + verticalPadding * 2 + 4));
471 476 Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
472 477 Canvas canvas = new Canvas(bitmap);
473   - canvas.drawColor(Color.WHITE);
  478 + canvas.drawColor(inverted ? Color.BLACK : Color.WHITE);
  479 + if (inverted && elementHeightDots > 0 && contentWidth > 0) {
  480 + Paint fill = new Paint();
  481 + fill.setColor(Color.BLACK);
  482 + canvas.drawRect(0, 0, width, height, fill);
  483 + }
474 484  
475 485 int topOffset = "TEXT_PRICE".equals(type)
476 486 ? Math.max(verticalPadding, (height - totalHeight) / 2)
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
... ... @@ -952,7 +952,7 @@ const goBluetoothPage = () => {
952 952  
953 953 .cat-icon-text--wrap,
954 954 .cat-icon-text--on-color {
955   - white-space: normal;
  955 + white-space: pre-wrap;
956 956 word-break: break-word;
957 957 overflow: hidden;
958 958 }
... ... @@ -1085,7 +1085,7 @@ const goBluetoothPage = () => {
1085 1085 padding: 0;
1086 1086 max-width: 100%;
1087 1087 max-height: 100%;
1088   - white-space: normal;
  1088 + white-space: pre-wrap;
1089 1089 word-break: break-word;
1090 1090 overflow: hidden;
1091 1091 }
... ... @@ -1238,14 +1238,14 @@ const goBluetoothPage = () => {
1238 1238 color: #111827;
1239 1239 line-height: 1.25;
1240 1240 text-align: center;
1241   - white-space: normal;
  1241 + white-space: pre-wrap;
1242 1242 word-break: break-word;
1243 1243 overflow: hidden;
1244 1244 box-sizing: border-box;
1245 1245 }
1246 1246  
1247 1247 .food-thumb-text--wrap {
1248   - white-space: normal;
  1248 + white-space: pre-wrap;
1249 1249 word-break: break-word;
1250 1250 }
1251 1251  
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -299,6 +299,7 @@ import {
299 299 templateIncludesEmployeeElement,
300 300 } from '../../utils/labelPreview/employeeElement'
301 301 import { applyLiveDateTimeFieldsToTemplate } from '../../utils/labelPreview/printInputOffset'
  302 +import { readInvertColors } from '../../utils/invertColorsConfig'
302 303 import {
303 304 getLabelPrintRasterLayout,
304 305 getPreviewCanvasCssSize,
... ... @@ -672,6 +673,9 @@ function applyNativeTemplateStyleScale(
672 673 // 时间/日期类右对齐文本在部分机型原生 TEXT 宽度估算有误差,强制位图可避免截断。
673 674 cfg.forceRasterText = true
674 675 }
  676 + if (readInvertColors(cfg)) {
  677 + cfg.forceRasterText = true
  678 + }
675 679 }
676 680 if (type === 'BARCODE') {
677 681 const raw = String(
... ... @@ -824,6 +828,8 @@ const systemTemplate = ref<SystemLabelTemplate | null>(null)
824 828 const basePreviewTemplate = ref<SystemLabelTemplate | null>(null)
825 829 /** 接口返回的 templateProductDefaults,每次合并/重绘按当前时刻重算日期时间 */
826 830 const previewProductDefaults = ref<Record<string, string>>({})
  831 +/** 最近一次 8.2 原始响应,便于 defaults 解析失败时重试 */
  832 +const lastPreviewRawPayload = ref<unknown>(null)
827 833 const printOptionSelections = ref<Record<string, string[]>>({})
828 834 const dictLabelsByElementId = ref<Record<string, string>>({})
829 835 const dictValuesByElementId = ref<Record<string, string[]>>({})
... ... @@ -1166,7 +1172,14 @@ function computeMergedPreviewTemplate(at?: Date): SystemLabelTemplate | null {
1166 1172 let base = basePreviewTemplate.value
1167 1173 if (!base) return null
1168 1174 const baseTime = at ?? new Date()
1169   - const defaults = previewProductDefaults.value
  1175 + let defaults = previewProductDefaults.value
  1176 + if (Object.keys(defaults).length === 0 && lastPreviewRawPayload.value != null) {
  1177 + const recovered = extractTemplateProductDefaultValuesFromPreviewPayload(lastPreviewRawPayload.value)
  1178 + if (Object.keys(recovered).length > 0) {
  1179 + defaults = recovered
  1180 + previewProductDefaults.value = recovered
  1181 + }
  1182 + }
1170 1183 if (Object.keys(defaults).length > 0) {
1171 1184 base = applyTemplateProductDefaultValuesToTemplate(base, defaults, baseTime)
1172 1185 }
... ... @@ -1332,6 +1345,7 @@ async function loadPreview() {
1332 1345 systemTemplate.value = null
1333 1346 basePreviewTemplate.value = null
1334 1347 previewProductDefaults.value = {}
  1348 + lastPreviewRawPayload.value = null
1335 1349 printOptionSelections.value = {}
1336 1350 printFreeFieldValues.value = {}
1337 1351 dictLabelsByElementId.value = {}
... ... @@ -1344,6 +1358,7 @@ async function loadPreview() {
1344 1358 productId: productId.value || undefined,
1345 1359 baseTime: new Date().toISOString(),
1346 1360 })
  1361 + lastPreviewRawPayload.value = raw
1347 1362 const root = raw as Record<string, unknown>
1348 1363 const nested = root.data ?? root.Data
1349 1364 const inner =
... ... @@ -1410,19 +1425,13 @@ async function loadPreview() {
1410 1425 }
1411 1426 const productDefaults = extractTemplateProductDefaultValuesFromPreviewPayload(raw)
1412 1427 previewProductDefaults.value = productDefaults
1413   - let tmplWithDefaults =
1414   - Object.keys(productDefaults).length > 0
1415   - ? applyTemplateProductDefaultValuesToTemplate(tmplRaw, productDefaults)
1416   - : tmplRaw
  1428 + let tmplBase = tmplRaw
1417 1429 const productCodeValue = extractProductCodeValueFromPreviewPayload(raw)
1418 1430 if (productCodeValue) {
1419   - tmplWithDefaults = applyProductCodeValueToTemplateScanElements(
1420   - tmplWithDefaults,
1421   - productCodeValue,
1422   - )
  1431 + tmplBase = applyProductCodeValueToTemplateScanElements(tmplBase, productCodeValue)
1423 1432 }
1424   - /** 画布像素仅按接口 template 的 width / height / unit 换算(与 renderLabelPreviewCanvas.toCanvasPx 一致),不用 labelSizeText 覆盖以免单位被误判 */
1425   - const base = overlayProductNameOnPreviewTemplate(tmplWithDefaults, displayProductName.value)
  1433 + /** 仅存原始模板 + 默认值;日期/时间在 computeMergedPreviewTemplate 内按同一 baseTime 重算一次 */
  1434 + const base = overlayProductNameOnPreviewTemplate(tmplBase, displayProductName.value)
1426 1435 basePreviewTemplate.value = base
1427 1436 printOptionSelections.value = readSelectionsFromTemplate(base)
1428 1437 printFreeFieldValues.value = ensureFreeFieldKeys(base, readFreeFieldValuesFromTemplate(base))
... ... @@ -1626,7 +1635,7 @@ const handlePrint = async () =&gt; {
1626 1635 * Virtual BT 不做 xScale/安全区收窄(易导致营养表错位、条码丢失);光栅仅作回退。
1627 1636 * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。
1628 1637 */
1629   - const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any)
  1638 + const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any, printBaseTime)
1630 1639 const currentDriver = getCurrentPrinterDriver()
1631 1640 const currentConn = getBluetoothConnection()
1632 1641 const isD320faxClassicNative =
... ...
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
... ... @@ -128,13 +128,12 @@ export async function fetchUsAppLabelingTree(input: {
128 128 })
129 129 }
130 130  
  131 +/** 离线缓存键:仅 location + labelCode + productId(不含 baseTime,避免同步预拉与预览页 key 不一致) */
131 132 export function buildLabelPreviewCacheKey(body: UsAppLabelPreviewInputVo): string {
132 133 const loc = String(body.locationId || '').trim()
133 134 const code = String(body.labelCode || '').trim()
134 135 const pid = String(body.productId || '').trim()
135   - const bt = String(body.baseTime || '').trim()
136   - const minuteBucket = bt ? bt.slice(0, 16) : ''
137   - return `preview:${loc}:${code}:${pid}:${minuteBucket}`
  136 + return `preview:${loc}:${code}:${pid}`
138 137 }
139 138  
140 139 /** 接口 8.2(在线写入 SQLite,离线读缓存) */
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
1 1 import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
2 2 import {
3 3 applyLiveDateTimeFieldsToTemplate,
  4 + isUniAppDateTimeOffsetField,
4 5 resolveTemplateDefaultValueForElement,
  6 + tryParsePrintInputOffsetStored,
5 7 } from './printInputOffset'
6 8 import { applyNutritionDefaultJsonToConfig } from './nutritionDefaultsMerge'
7 9  
... ... @@ -168,6 +170,41 @@ export function applyLabelSizeTextToTemplate(
168 170 }
169 171  
170 172 /**
  173 + * 将接口/缓存中的默认值统一为字符串(兼容 object、双重 JSON 编码、PascalCase unit/value)。
  174 + */
  175 +export function coerceTemplateProductDefaultValueToString(v: unknown): string {
  176 + if (v == null) return ''
  177 + if (typeof v === 'string') {
  178 + const t = v.trim()
  179 + if (!t) return ''
  180 + if (t.startsWith('{') || t.startsWith('[')) return t
  181 + if (t.startsWith('"') && t.endsWith('"')) {
  182 + try {
  183 + const inner = JSON.parse(t) as unknown
  184 + if (typeof inner === 'string') return inner.trim()
  185 + } catch {
  186 + /* ignore */
  187 + }
  188 + }
  189 + return t
  190 + }
  191 + if (typeof v === 'number' || typeof v === 'boolean') return String(v)
  192 + if (typeof v === 'object' && !Array.isArray(v)) {
  193 + const rec = v as Record<string, unknown>
  194 + const unit = rec.unit ?? rec.Unit
  195 + const value = rec.value ?? rec.Value
  196 + if (unit != null && value != null) {
  197 + return JSON.stringify({ unit: String(unit), value: String(value) })
  198 + }
  199 + }
  200 + try {
  201 + return JSON.stringify(v)
  202 + } catch {
  203 + return String(v)
  204 + }
  205 +}
  206 +
  207 +/**
171 208 * 从预览接口响应中取出 `templateProductDefaultValues`(elementId → 字符串,兼容 PascalCase / 嵌套 data)。
172 209 */
173 210 export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: unknown): Record<string, string> {
... ... @@ -180,10 +217,7 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u
180 217 for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
181 218 const key = String(k).trim()
182 219 if (!key) continue
183   - if (v == null) out[key] = ''
184   - else if (typeof v === 'string') out[key] = v
185   - else if (typeof v === 'number' || typeof v === 'boolean') out[key] = String(v)
186   - else out[key] = JSON.stringify(v)
  220 + out[key] = coerceTemplateProductDefaultValueToString(v)
187 221 }
188 222 return Object.keys(out).length ? out : null
189 223 }
... ... @@ -236,9 +270,32 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u
236 270 inner ? tryLayer(inner) : null,
237 271 tryLayer(r),
238 272 tryLayer(payload),
  273 + tryExtractFromTemplateProductDefaultsArray(r),
  274 + inner ? tryExtractFromTemplateProductDefaultsArray(inner) : null,
239 275 )
240 276 }
241 277  
  278 +/** Web 模板 DTO 的 templateProductDefaults 数组 → 扁平 elementId → value */
  279 +function tryExtractFromTemplateProductDefaultsArray(layer: unknown): Record<string, string> | null {
  280 + if (layer == null || typeof layer !== 'object' || Array.isArray(layer)) return null
  281 + const L = layer as Record<string, unknown>
  282 + const raw = L.templateProductDefaults ?? L.TemplateProductDefaults
  283 + if (!Array.isArray(raw)) return null
  284 + const out: Record<string, string> = {}
  285 + for (const row of raw) {
  286 + if (row == null || typeof row !== 'object' || Array.isArray(row)) continue
  287 + const rec = row as Record<string, unknown>
  288 + const dv = rec.defaultValues ?? rec.DefaultValues
  289 + if (dv == null || typeof dv !== 'object' || Array.isArray(dv)) continue
  290 + for (const [k, v] of Object.entries(dv as Record<string, unknown>)) {
  291 + const key = String(k).trim()
  292 + if (!key) continue
  293 + out[key] = coerceTemplateProductDefaultValueToString(v)
  294 + }
  295 + }
  296 + return Object.keys(out).length ? out : null
  297 +}
  298 +
242 299 function isTemplateSectionScanElement(el: SystemTemplateElementBase): boolean {
243 300 const cfg = el.config || {}
244 301 const typeAdd = String(
... ... @@ -313,6 +370,30 @@ export function applyProductCodeValueToTemplateScanElements(
313 370 }
314 371  
315 372 /**
  373 + * 从 templateProductDefaultValues 按 id / inputKey / elementName 匹配(大小写不敏感兜底)。
  374 + */
  375 +export function lookupTemplateProductDefaultValue(
  376 + el: SystemTemplateElementBase,
  377 + defaults: Record<string, string>,
  378 +): string | undefined {
  379 + const candidates = [
  380 + String(el.id ?? '').trim(),
  381 + String(el.inputKey ?? '').trim(),
  382 + String(el.elementName ?? '').trim(),
  383 + ].filter(Boolean)
  384 + for (const k of candidates) {
  385 + if (Object.prototype.hasOwnProperty.call(defaults, k)) return defaults[k]
  386 + }
  387 + const lowerEntries = Object.entries(defaults).map(([k, v]) => [k.toLowerCase(), v] as const)
  388 + for (const k of candidates) {
  389 + const lk = k.toLowerCase()
  390 + const hit = lowerEntries.find(([dk]) => dk === lk)
  391 + if (hit) return hit[1]
  392 + }
  393 + return undefined
  394 +}
  395 +
  396 +/**
316 397 * 将平台录入的默认值合并进模板元素 config,供画布预览(键与 elements[].id 一致)。
317 398 */
318 399 export function applyTemplateProductDefaultValuesToTemplate(
... ... @@ -323,9 +404,7 @@ export function applyTemplateProductDefaultValuesToTemplate(
323 404 const keys = Object.keys(defaults)
324 405 if (!keys.length) return template
325 406 const elements = (template.elements || []).map((el) => {
326   - const byName = (el.elementName ?? '').trim()
327   - const v =
328   - defaults[el.id] ?? (byName ? defaults[byName] : undefined)
  407 + const v = lookupTemplateProductDefaultValue(el, defaults)
329 408 if (v === undefined) return el
330 409 const type = String(el.type || '').toUpperCase()
331 410 const cfg = { ...(el.config || {}) } as Record<string, any>
... ... @@ -359,9 +438,15 @@ export function applyTemplateProductDefaultValuesToTemplate(
359 438 }
360 439  
361 440 if (type === 'DATE' || type === 'TIME' || type === 'DURATION') {
362   - const text = resolveTemplateDefaultValueForElement(el, v, base)
363   - cfg.text = text
364   - cfg.Text = text
  441 + /** 偏移类字段保留 JSON(如 {"unit":"Days","value":"2"}),供 applyLiveDateTimeFieldsToTemplate 按出纸时刻重算 */
  442 + if (isUniAppDateTimeOffsetField(el) && tryParsePrintInputOffsetStored(v)) {
  443 + cfg.text = v
  444 + cfg.Text = v
  445 + } else {
  446 + const text = resolveTemplateDefaultValueForElement(el, v, base)
  447 + cfg.text = text
  448 + cfg.Text = text
  449 + }
365 450 return { ...el, config: cfg }
366 451 }
367 452  
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/printInputOffset.ts
... ... @@ -224,13 +224,46 @@ export function resolveElementDateTimeDisplay (
224 224 type === 'TIME'
225 225 ) {
226 226 const off = readOffsetFromElementConfig(el)
227   - return resolveFromOffsetAmount(el, off?.amount ?? 0, off?.unit ?? 'Days', base)
  227 + if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base)
  228 + /** 默认值已展开为字面日期时仍保留,避免丢失 durationdate 等 +N Days */
  229 + if (
  230 + rawText &&
  231 + !tryParsePrintInputOffsetStored(rawText) &&
  232 + isLikelyResolvedDateTimeLiteral(rawText)
  233 + ) {
  234 + return applyElementPrefix(cfg, rawText)
  235 + }
  236 + return resolveFromOffsetAmount(el, 0, 'Days', base)
228 237 }
229 238 return null
230 239 }
231 240  
  241 + /** FIXED 的 durationdate/durationtime 等:config.text 可能已是平台录入的偏移 JSON */
  242 + if (vst === 'FIXED' && isUniAppDateTimeOffsetField(el)) {
  243 + const off = readOffsetFromElementConfig(el)
  244 + if (off && (off.amount !== 0 || rawText)) {
  245 + return resolveFromOffsetAmount(el, off.amount, off.unit, base)
  246 + }
  247 + if (
  248 + rawText &&
  249 + !tryParsePrintInputOffsetStored(rawText) &&
  250 + isLikelyResolvedDateTimeLiteral(rawText)
  251 + ) {
  252 + return applyElementPrefix(cfg, rawText)
  253 + }
  254 + return resolveFromOffsetAmount(el, 0, 'Days', base)
  255 + }
  256 +
232 257 const off = readOffsetFromElementConfig(el)
233 258 if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base)
  259 + if (
  260 + rawText &&
  261 + !tryParsePrintInputOffsetStored(rawText) &&
  262 + isLikelyResolvedDateTimeLiteral(rawText) &&
  263 + isUniAppDateTimeOffsetField(el)
  264 + ) {
  265 + return applyElementPrefix(cfg, rawText)
  266 + }
234 267 return resolveFromOffsetAmount(el, 0, 'Days', base)
235 268 }
236 269  
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
1 1 import type { RawImageDataSource, SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
2 2 import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl'
3 3 import { sortElementsForPreview, normalizeTemplatePrintOrientation } from './normalizePreviewTemplate'
4   -import { resolveElementDateTimeDisplay } from './printInputOffset'
  4 +import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral } from './printInputOffset'
5 5 import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement'
6 6 import QRCode from 'qrcode'
7 7 import { readInvertColors } from '../invertColorsConfig'
... ... @@ -134,6 +134,11 @@ function previewTextForElement(element: SystemTemplateElementBase, baseTime: Dat
134 134 return applyConfigPrefix(config, getLoggedInEmployeeDisplayName())
135 135 }
136 136 if (type === 'DATE' || type === 'TIME' || type === 'DURATION') {
  137 + /** applyLiveDateTimeFieldsToTemplate 已写入 config.text 时直接展示,避免二次 resolve 把 +N Days 重置为当天 */
  138 + const baked = cfgStr(config, ['text', 'Text'], '').trim()
  139 + if (baked && isLikelyResolvedDateTimeLiteral(baked)) {
  140 + return applyConfigPrefix(config, baked)
  141 + }
137 142 const live = resolveElementDateTimeDisplay(element, baseTime)
138 143 if (live != null && live.trim()) return live
139 144 }
... ... @@ -661,10 +666,15 @@ function runLabelPreviewCanvasDraw(
661 666 else if (align === 'right') tx = x + w - pad
662 667 ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left')
663 668 const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15))
664   - const maxChars = maxCharsPerLine(innerW, fontSize)
665   - const lines = wrapTextToWidth(text, maxChars)
666   - const maxLines =
667   - innerH >= fontSize ? Math.max(1, Math.floor(innerH / lineHeight)) : lines.length
  669 + const isDateTimeType = type === 'DATE' || type === 'TIME' || type === 'DURATION'
  670 + const lines = isDateTimeType
  671 + ? [String(text).replace(/\s+/g, ' ').trim()]
  672 + : wrapTextToWidth(text, maxCharsPerLine(innerW, fontSize))
  673 + const maxLines = isDateTimeType
  674 + ? 1
  675 + : innerH >= fontSize
  676 + ? Math.max(1, Math.floor(innerH / lineHeight))
  677 + : lines.length
668 678 const startY = y + pad + fontSize
669 679 lines.slice(0, maxLines).forEach((ln, li) => {
670 680 ctx.fillText(ln, tx, startY + li * lineHeight)
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
... ... @@ -10,6 +10,7 @@ import type {
10 10 import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat'
11 11 import { applyTemplateData } from './templateRenderer'
12 12 import { resolveElementDateTimeDisplay } from '../labelPreview/printInputOffset'
  13 +import { readInvertColors } from '../invertColorsConfig'
13 14  
14 15 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean {
15 16 const type = String(el.type || '').toUpperCase()
... ... @@ -95,9 +96,10 @@ function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): Sy
95 96 */
96 97 export function normalizeTemplateForNativeFastJob (
97 98 template: SystemLabelTemplate,
98   - data: LabelTemplateData
  99 + data: LabelTemplateData,
  100 + baseTime: Date = new Date(),
99 101 ): SystemLabelTemplate {
100   - const now = new Date()
  102 + const now = baseTime
101 103  
102 104 const extras: SystemTemplateElementBase[] = []
103 105 const elements = (template.elements || []).map((el) => {
... ... @@ -125,10 +127,14 @@ export function normalizeTemplateForNativeFastJob (
125 127 if (v && u && !v.endsWith(u)) text = `${v}${u}`
126 128 else text = v || u
127 129 }
  130 + const nextConfig: Record<string, unknown> = { ...config, text, nativeSourceType: type }
  131 + if (readInvertColors(config)) {
  132 + nextConfig.forceRasterText = true
  133 + }
128 134 return {
129 135 ...el,
130 136 type: 'TEXT_STATIC' as typeof el.type,
131   - config: { ...config, text, nativeSourceType: type },
  137 + config: nextConfig,
132 138 }
133 139 }
134 140 if (type === 'NUTRITION') {
... ... @@ -164,7 +170,14 @@ export function normalizeTemplateForNativeFastJob (
164 170 }
165 171 return el
166 172 })
167   - const merged = resolveNativeLayoutCollisions([...elements, ...extras])
  173 + const withInvertRaster = elements.map((el) => {
  174 + const cfg = { ...(el.config || {}) } as Record<string, unknown>
  175 + if (readInvertColors(cfg)) {
  176 + cfg.forceRasterText = true
  177 + }
  178 + return { ...el, config: cfg }
  179 + })
  180 + const merged = resolveNativeLayoutCollisions([...withInvertRaster, ...extras])
168 181 return { ...template, elements: merged }
169 182 }
170 183  
... ...
美国版/Food Labeling Management App UniApp/src/utils/sqliteSync.ts
... ... @@ -207,6 +207,28 @@ export async function hasOfflineCache(module: string, name: string): Promise&lt;boo
207 207 return rows.length > 0
208 208 }
209 209  
  210 +/** 离线读缓存:匹配以 prefix 开头的 cache_name(兼容旧版 preview 键带 baseTime 后缀) */
  211 +async function findOfflineCacheKeyByPrefix(module: string, namePrefix: string): Promise<string | null> {
  212 + if (!namePrefix) return null
  213 + if (!isAppSqliteAvailable()) {
  214 + const map = getStorageMap<{ module: string; name: string }>(FALLBACK_CACHE_KEY)
  215 + for (const row of Object.values(map)) {
  216 + const m = String((row as { module?: string }).module || '')
  217 + const n = String((row as { name?: string }).name || '')
  218 + if (m === module && n.startsWith(namePrefix)) return n
  219 + }
  220 + return null
  221 + }
  222 + await initOfflineSqlite()
  223 + const rows = await sqliteSelect(
  224 + `SELECT cache_name FROM ${CACHE_TABLE}
  225 + WHERE module_name='${esc(module)}' AND cache_name LIKE '${esc(namePrefix)}%'
  226 + ORDER BY updated_at DESC LIMIT 1`
  227 + )
  228 + const hit = rows?.[0]?.cache_name
  229 + return hit != null && String(hit).trim() ? String(hit) : null
  230 +}
  231 +
210 232 export async function getOfflineCache<T = unknown>(module: string, name: string): Promise<T | null> {
211 233 const key = cachePk(module, name)
212 234 if (!isAppSqliteAvailable()) {
... ... @@ -399,6 +421,14 @@ export async function fetchWithOfflineCache&lt;T&gt;(
399 421 if (await hasOfflineCache(module, name)) {
400 422 return (await getOfflineCache<T>(module, name)) as T
401 423 }
  424 + /** 兼容旧版 preview 缓存键(含 baseTime 分钟桶) */
  425 + if (name.startsWith('preview:')) {
  426 + const legacyPrefix = `${name}:`
  427 + const legacyHit = await findOfflineCacheKeyByPrefix(module, legacyPrefix)
  428 + if (legacyHit) {
  429 + return (await getOfflineCache<T>(module, legacyHit)) as T
  430 + }
  431 + }
402 432 throw new Error('No offline cache available')
403 433 }
404 434  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
... ... @@ -463,6 +463,11 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
463 463 }
464 464 }
465 465  
  466 + if (templateProductDefaultValues is { Count: > 0 })
  467 + {
  468 + ApplyTemplateProductDefaultValuesToPreviewElements(template.Elements, templateProductDefaultValues);
  469 + }
  470 +
466 471 return new UsAppLabelPreviewDto
467 472 {
468 473 LabelId = labelRow.Id,
... ... @@ -1177,6 +1182,177 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
1177 1182 return Math.Round((current - previous) * 100m / previous, 2);
1178 1183 }
1179 1184  
  1185 + /// <summary>
  1186 + /// 将 fl_label_template_product_default 默认值写入预览模板元素 config(日期偏移保留 JSON,由客户端按 BaseTime 展开)。
  1187 + /// </summary>
  1188 + private static void ApplyTemplateProductDefaultValuesToPreviewElements(
  1189 + IList<LabelTemplateElementDto>? elements,
  1190 + Dictionary<string, object?> defaults)
  1191 + {
  1192 + if (elements == null || elements.Count == 0 || defaults == null || defaults.Count == 0)
  1193 + {
  1194 + return;
  1195 + }
  1196 +
  1197 + foreach (var el in elements)
  1198 + {
  1199 + var stored = ResolveTemplateProductDefaultValueForElement(el, defaults);
  1200 + if (string.IsNullOrWhiteSpace(stored))
  1201 + {
  1202 + continue;
  1203 + }
  1204 +
  1205 + var cfg = ParsePreviewElementConfig(el.ConfigJson);
  1206 + var type = (el.ElementType ?? string.Empty).Trim().ToUpperInvariant();
  1207 +
  1208 + switch (type)
  1209 + {
  1210 + case "DATE":
  1211 + case "TIME":
  1212 + case "DURATION":
  1213 + cfg["text"] = stored;
  1214 + cfg["Text"] = stored;
  1215 + break;
  1216 + case "IMAGE":
  1217 + case "LOGO":
  1218 + cfg["src"] = stored;
  1219 + cfg["Src"] = stored;
  1220 + cfg["url"] = stored;
  1221 + cfg["Url"] = stored;
  1222 + break;
  1223 + case "BARCODE":
  1224 + case "QRCODE":
  1225 + cfg["data"] = stored;
  1226 + cfg["Data"] = stored;
  1227 + break;
  1228 + default:
  1229 + cfg["text"] = stored;
  1230 + cfg["Text"] = stored;
  1231 + break;
  1232 + }
  1233 +
  1234 + el.ConfigJson = cfg;
  1235 + }
  1236 + }
  1237 +
  1238 + private static string? ResolveTemplateProductDefaultValueForElement(
  1239 + LabelTemplateElementDto el,
  1240 + Dictionary<string, object?> defaults)
  1241 + {
  1242 + var candidates = new[]
  1243 + {
  1244 + el.Id?.Trim(),
  1245 + el.InputKey?.Trim(),
  1246 + el.ElementName?.Trim(),
  1247 + }.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
  1248 +
  1249 + foreach (var key in candidates)
  1250 + {
  1251 + if (defaults.TryGetValue(key!, out var direct))
  1252 + {
  1253 + return TemplateProductDefaultValueToStoredString(direct);
  1254 + }
  1255 + }
  1256 +
  1257 + foreach (var kv in defaults)
  1258 + {
  1259 + foreach (var key in candidates)
  1260 + {
  1261 + if (string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase))
  1262 + {
  1263 + return TemplateProductDefaultValueToStoredString(kv.Value);
  1264 + }
  1265 + }
  1266 + }
  1267 +
  1268 + return null;
  1269 + }
  1270 +
  1271 + private static string? TemplateProductDefaultValueToStoredString(object? value)
  1272 + {
  1273 + if (value is null)
  1274 + {
  1275 + return null;
  1276 + }
  1277 +
  1278 + if (value is string s)
  1279 + {
  1280 + return s;
  1281 + }
  1282 +
  1283 + if (value is JsonElement je)
  1284 + {
  1285 + if (je.ValueKind == JsonValueKind.String)
  1286 + {
  1287 + return je.GetString();
  1288 + }
  1289 +
  1290 + if (je.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
  1291 + {
  1292 + return je.GetRawText();
  1293 + }
  1294 +
  1295 + return je.ToString();
  1296 + }
  1297 +
  1298 + if (value is bool or int or long or decimal or double or float)
  1299 + {
  1300 + return Convert.ToString(value, CultureInfo.InvariantCulture);
  1301 + }
  1302 +
  1303 + return JsonSerializer.Serialize(value);
  1304 + }
  1305 +
  1306 + private static Dictionary<string, object?> ParsePreviewElementConfig(object? configJson)
  1307 + {
  1308 + if (configJson is null)
  1309 + {
  1310 + return new Dictionary<string, object?>();
  1311 + }
  1312 +
  1313 + if (configJson is Dictionary<string, object?> dict)
  1314 + {
  1315 + return new Dictionary<string, object?>(dict);
  1316 + }
  1317 +
  1318 + if (configJson is JsonElement je && je.ValueKind == JsonValueKind.Object)
  1319 + {
  1320 + try
  1321 + {
  1322 + return JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
  1323 + ?? new Dictionary<string, object?>();
  1324 + }
  1325 + catch
  1326 + {
  1327 + return new Dictionary<string, object?>();
  1328 + }
  1329 + }
  1330 +
  1331 + if (configJson is string raw && !string.IsNullOrWhiteSpace(raw))
  1332 + {
  1333 + try
  1334 + {
  1335 + return JsonSerializer.Deserialize<Dictionary<string, object?>>(raw)
  1336 + ?? new Dictionary<string, object?>();
  1337 + }
  1338 + catch
  1339 + {
  1340 + return new Dictionary<string, object?>();
  1341 + }
  1342 + }
  1343 +
  1344 + try
  1345 + {
  1346 + var json = JsonSerializer.Serialize(configJson);
  1347 + return JsonSerializer.Deserialize<Dictionary<string, object?>>(json)
  1348 + ?? new Dictionary<string, object?>();
  1349 + }
  1350 + catch
  1351 + {
  1352 + return new Dictionary<string, object?>();
  1353 + }
  1354 + }
  1355 +
1180 1356 private async Task<string?> ResolveTemplateProductDefaultValuesJsonAsync(
1181 1357 string templateId,
1182 1358 string? productId,
... ...
美国版/Food Labeling Management Platform/.env.local
1   -VITE_API_BASE_URL=http://192.168.31.88:19001
2   -# VITE_API_BASE_URL=http://flus-test.3ffoodsafety.com
  1 +# VITE_API_BASE_URL=http://192.168.31.88:19001
  2 + VITE_API_BASE_URL=http://flus-test.3ffoodsafety.com
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
... ... @@ -8,6 +8,7 @@ import {
8 8 TableRow,
9 9 } from "../ui/table";
10 10 import { Input } from "../ui/input";
  11 +import { Textarea } from "../ui/textarea";
11 12 import { Button } from "../ui/button";
12 13 import {
13 14 Select,
... ... @@ -1095,14 +1096,14 @@ function CreateLabelCategoryDialog({
1095 1096 {apSel.text && !apSel.image ? (
1096 1097 <div className="space-y-2">
1097 1098 <Label>Display Text</Label>
1098   - <Input
1099   - className="h-10"
  1099 + <Textarea
  1100 + className="min-h-[4.5rem] resize-y"
1100 1101 placeholder="Category Name"
1101 1102 value={displayTextForPhoto}
1102 1103 onChange={(e) => setDisplayTextForPhoto(e.target.value)}
1103 1104 />
1104 1105 <div className="text-xs text-gray-500">
1105   - Saved to <span className="font-mono">photo</span> as this text.
  1106 + Press Enter for a new line. Multiple spaces collapse to one; use Enter to break lines.
1106 1107 </div>
1107 1108 </div>
1108 1109 ) : null}
... ... @@ -1566,14 +1567,14 @@ function EditLabelCategoryDialog({
1566 1567 {apSel.text && !apSel.image ? (
1567 1568 <div className="space-y-2">
1568 1569 <Label>Display Text</Label>
1569   - <Input
1570   - className="h-10"
  1570 + <Textarea
  1571 + className="min-h-[4.5rem] resize-y"
1571 1572 placeholder="Category Name"
1572 1573 value={displayTextForPhoto}
1573 1574 onChange={(e) => setDisplayTextForPhoto(e.target.value)}
1574 1575 />
1575 1576 <div className="text-xs text-gray-500">
1576   - Saved to <span className="font-mono">photo</span> as this text.
  1577 + Press Enter for a new line. Multiple spaces collapse to one; use Enter to break lines.
1577 1578 </div>
1578 1579 </div>
1579 1580 ) : null}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
... ... @@ -110,6 +110,7 @@ import {
110 110 regionNamesToGroupIds,
111 111 regionOptionsForPartner,
112 112 scopePartnerValidationMessage,
  113 + buildSpecifiedLocationPayload,
113 114 } from "../../lib/categoryScopeForm";
114 115  
115 116 function toDisplay(v: string | null | undefined): string {
... ... @@ -308,7 +309,12 @@ function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput {
308 309 return {
309 310 labelName: d.labelName ?? "",
310 311 templateCode: d.templateCode ?? "",
311   - locationId: d.locationId ?? "",
  312 + locationIds: (() => {
  313 + const fromList = (d.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean);
  314 + if (fromList.length) return [...new Set(fromList)];
  315 + const single = (d.locationId ?? "").trim();
  316 + return single ? [single] : [];
  317 + })(),
312 318 labelCategoryId: d.labelCategoryId ?? "",
313 319 labelTypeId: d.labelTypeId ?? "",
314 320 /** 编辑表单仅支持单商品:多商品时取第一个 */
... ... @@ -1762,7 +1768,7 @@ function CreateLabelDialog({
1762 1768 const [form, setForm] = useState<LabelCreateInput>({
1763 1769 labelName: "",
1764 1770 templateCode: "",
1765   - locationId: "",
  1771 + locationIds: [],
1766 1772 labelCategoryId: "",
1767 1773 labelTypeId: "",
1768 1774 productIds: [],
... ... @@ -1774,7 +1780,7 @@ function CreateLabelDialog({
1774 1780 setForm({
1775 1781 labelName: "",
1776 1782 templateCode: "",
1777   - locationId: "",
  1783 + locationIds: [],
1778 1784 labelCategoryId: "",
1779 1785 labelTypeId: "",
1780 1786 productIds: [],
... ... @@ -1840,7 +1846,7 @@ function CreateLabelDialog({
1840 1846 setForm((p) => ({
1841 1847 ...p,
1842 1848 templateCode: "",
1843   - locationId: "",
  1849 + locationIds: [],
1844 1850 labelCategoryId: "",
1845 1851 labelTypeId: "",
1846 1852 productIds: [],
... ... @@ -1858,13 +1864,23 @@ function CreateLabelDialog({
1858 1864 setForm((p) => ({
1859 1865 ...p,
1860 1866 templateCode: "",
1861   - locationId: "",
  1867 + locationIds: [],
1862 1868 labelCategoryId: "",
1863 1869 labelTypeId: "",
1864 1870 productIds: [],
1865 1871 }));
1866 1872 }, [open, scopePartnerId]);
1867 1873  
  1874 + useEffect(() => {
  1875 + if (!regionScopeReady) return;
  1876 + const allowed = new Set(locationsInScope.map((l) => l.id));
  1877 + setForm((p) => {
  1878 + const next = (p.locationIds ?? []).filter((id) => allowed.has(id));
  1879 + if (next.length === (p.locationIds ?? []).length) return p;
  1880 + return { ...p, locationIds: next };
  1881 + });
  1882 + }, [locationsInScope, regionScopeReady]);
  1883 +
1868 1884 const companySelectOptionsForCreate = useMemo(
1869 1885 () =>
1870 1886 [...partners]
... ... @@ -2035,9 +2051,21 @@ function CreateLabelDialog({
2035 2051 });
2036 2052 return;
2037 2053 }
2038   - if (!form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) {
  2054 + const locPayload = buildSpecifiedLocationPayload(
  2055 + locations,
  2056 + partners,
  2057 + groups,
  2058 + effectivePartnerId,
  2059 + selectedRegionNames,
  2060 + form.locationIds,
  2061 + );
  2062 + if (!locPayload.ok) {
  2063 + toast.error("Validation failed", { description: locPayload.message });
  2064 + return;
  2065 + }
  2066 + if (!form.labelName.trim() || !form.templateCode.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) {
2039 2067 toast.error("Validation failed", {
2040   - description: "Fill all required fields and select template, location, category, and type.",
  2068 + description: "Fill all required fields and select template, location(s), category, and type.",
2041 2069 });
2042 2070 return;
2043 2071 }
... ... @@ -2118,6 +2146,7 @@ function CreateLabelDialog({
2118 2146 try {
2119 2147 await createLabel({
2120 2148 ...form,
  2149 + locationIds: locPayload.locationIds,
2121 2150 appliedRegionType: regionPayload.appliedRegionType,
2122 2151 regionIds: regionPayload.regionIds,
2123 2152 groupIds: regionPayload.regionIds,
... ... @@ -2415,15 +2444,21 @@ function CreateLabelDialog({
2415 2444 </div>
2416 2445 <div className="space-y-2">
2417 2446 <Label>Location *</Label>
2418   - <SearchableSelect
2419   - value={form.locationId}
2420   - onValueChange={(v) => setForm((p) => ({ ...p, locationId: v }))}
  2447 + <SearchableMultiSelect
  2448 + values={form.locationIds}
  2449 + onValuesChange={(ids) => setForm((p) => ({ ...p, locationIds: ids }))}
2421 2450 options={locationOptions}
2422   - placeholder={regionScopeReady ? "Select location" : scopeGateHint}
  2451 + placeholder={regionScopeReady ? "Select location(s)…" : scopeGateHint}
2423 2452 searchPlaceholder="Search location…"
  2453 + selectAllRowLabel="Select All"
2424 2454 emptyText={regionScopeReady ? "No locations in this region." : scopeGateEmpty}
2425 2455 disabled={scopeBootstrapLoading || !regionScopeReady || scopedOptionsLoading}
2426 2456 />
  2457 + <p className="text-xs text-gray-500">
  2458 + {regionScopeReady
  2459 + ? "Select All applies this label to every location in the selected region(s)."
  2460 + : scopeGateEmpty}
  2461 + </p>
2427 2462 </div>
2428 2463 <div className="space-y-2">
2429 2464 <Label>Label Category *</Label>
... ... @@ -2672,7 +2707,7 @@ function EditLabelDialog({
2672 2707 const [form, setForm] = useState<LabelUpdateInput>({
2673 2708 labelName: "",
2674 2709 templateCode: "",
2675   - locationId: "",
  2710 + locationIds: [],
2676 2711 labelCategoryId: "",
2677 2712 labelTypeId: "",
2678 2713 productIds: [],
... ... @@ -2988,9 +3023,21 @@ function EditLabelDialog({
2988 3023 });
2989 3024 return;
2990 3025 }
2991   - if (!form.labelName.trim() || !form.templateCode.trim() || !form.locationId.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) {
  3026 + const locPayload = buildSpecifiedLocationPayload(
  3027 + locations,
  3028 + partners,
  3029 + groups,
  3030 + effectivePartnerId,
  3031 + selectedRegionNames,
  3032 + form.locationIds,
  3033 + );
  3034 + if (!locPayload.ok) {
  3035 + toast.error("Validation failed", { description: locPayload.message });
  3036 + return;
  3037 + }
  3038 + if (!form.labelName.trim() || !form.templateCode.trim() || !form.labelCategoryId.trim() || !form.labelTypeId.trim()) {
2992 3039 toast.error("Validation failed", {
2993   - description: "Fill all required fields and select template, location, category, and type.",
  3040 + description: "Fill all required fields and select template, location(s), category, and type.",
2994 3041 });
2995 3042 return;
2996 3043 }
... ... @@ -3071,6 +3118,7 @@ function EditLabelDialog({
3071 3118 try {
3072 3119 await updateLabel(label.id, {
3073 3120 ...form,
  3121 + locationIds: locPayload.locationIds,
3074 3122 appliedRegionType: regionPayload.appliedRegionType,
3075 3123 regionIds: regionPayload.regionIds,
3076 3124 groupIds: regionPayload.regionIds,
... ... @@ -3116,12 +3164,20 @@ function EditLabelDialog({
3116 3164 value: loc.id,
3117 3165 label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id),
3118 3166 }));
3119   - const id = form.locationId;
3120   - if (id && !base.some((o) => o.value === id)) {
3121   - return [{ value: id, label: `${id} (current)` }, ...base];
3122   - }
3123   - return base;
3124   - }, [editScopeReady, locationsInEditScope, locations, form.locationId]);
  3167 + const missing = (form.locationIds ?? []).filter((id) => id && !base.some((o) => o.value === id));
  3168 + const extras = missing.map((id) => ({ value: id, label: `${id} (current)` }));
  3169 + return extras.length ? [...extras, ...base] : base;
  3170 + }, [editScopeReady, locationsInEditScope, locations, form.locationIds]);
  3171 +
  3172 + useEffect(() => {
  3173 + if (!editScopeReady) return;
  3174 + const allowed = new Set(locationsInEditScope.map((l) => l.id));
  3175 + setForm((p) => {
  3176 + const next = (p.locationIds ?? []).filter((id) => allowed.has(id));
  3177 + if (next.length === (p.locationIds ?? []).length) return p;
  3178 + return { ...p, locationIds: next };
  3179 + });
  3180 + }, [locationsInEditScope, editScopeReady]);
3125 3181  
3126 3182 const editCategoryOptions = useMemo(() => {
3127 3183 const base = labelCategoriesScopedEdit.map((c) => ({
... ... @@ -3351,17 +3407,23 @@ function EditLabelDialog({
3351 3407 </div>
3352 3408 <div className="space-y-2">
3353 3409 <Label>Location *</Label>
3354   - <SearchableSelect
3355   - value={form.locationId}
3356   - onValueChange={(v) => setForm((p) => ({ ...p, locationId: v }))}
  3410 + <SearchableMultiSelect
  3411 + values={form.locationIds}
  3412 + onValuesChange={(ids) => setForm((p) => ({ ...p, locationIds: ids }))}
3357 3413 options={editLocationOptions}
3358   - placeholder={editScopeReady ? "Select location" : "Select applicable region(s) first"}
  3414 + placeholder={editScopeReady ? "Select location(s)…" : "Select applicable region(s) first"}
3359 3415 searchPlaceholder="Search location…"
  3416 + selectAllRowLabel="Select All"
3360 3417 emptyText={
3361 3418 editScopeReady ? "No locations in selected regions." : "Select applicable region(s) first."
3362 3419 }
3363 3420 disabled={refLoading || detailLoading || !editScopeReady || editScopedLoading}
3364 3421 />
  3422 + <p className="text-xs text-gray-500">
  3423 + {editScopeReady
  3424 + ? "Select All applies this label to every location in the selected region(s)."
  3425 + : "Select applicable region(s) first."}
  3426 + </p>
3365 3427 </div>
3366 3428 </div>
3367 3429  
... ...
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
... ... @@ -13,6 +13,7 @@ import {
13 13 import { Button } from "../ui/button";
14 14 import { Checkbox } from "../ui/checkbox";
15 15 import { Input } from "../ui/input";
  16 +import { Textarea } from "../ui/textarea";
16 17 import {
17 18 Table,
18 19 TableBody,
... ... @@ -1917,14 +1918,14 @@ function ProductFormDialog({
1917 1918 {apSel.text && !apSel.image ? (
1918 1919 <div className="space-y-2">
1919 1920 <Label>Display Text</Label>
1920   - <Input
1921   - className="h-10"
  1921 + <Textarea
  1922 + className="min-h-[4.5rem] resize-y"
1922 1923 value={displayText}
1923 1924 onChange={(e) => setDisplayText(e.target.value)}
1924 1925 placeholder="Product Name"
1925 1926 />
1926 1927 <div className="text-xs text-gray-500">
1927   - Saved to <span className="font-mono">photo</span> as this text.
  1928 + Press Enter for a new line. Multiple spaces collapse to one; use Enter to break lines.
1928 1929 </div>
1929 1930 </div>
1930 1931 ) : null}
... ... @@ -2480,14 +2481,14 @@ function ProductCategoryFormDialog({
2480 2481 {apSel.text && !apSel.image ? (
2481 2482 <div className="space-y-2">
2482 2483 <Label>Display Text</Label>
2483   - <Input
2484   - className="h-10"
  2484 + <Textarea
  2485 + className="min-h-[4.5rem] resize-y"
2485 2486 value={displayText}
2486 2487 onChange={(e) => setDisplayText(e.target.value)}
2487 2488 placeholder="Category Name"
2488 2489 />
2489 2490 <div className="text-xs text-gray-500">
2490   - Saved to <span className="font-mono">photo</span> as this text.
  2491 + Press Enter for a new line. Multiple spaces collapse to one; use Enter to break lines.
2491 2492 </div>
2492 2493 </div>
2493 2494 ) : null}
... ...
美国版/Food Labeling Management Platform/src/components/ui/category-button-visual-thumb.tsx
... ... @@ -16,7 +16,7 @@ const TEXT_WRAP_STYLE: CSSProperties = {
16 16 width: "100%",
17 17 maxWidth: "100%",
18 18 minWidth: 0,
19   - whiteSpace: "normal",
  19 + whiteSpace: "pre-wrap",
20 20 wordBreak: "break-word",
21 21 overflow: "hidden",
22 22 textAlign: "center",
... ...
美国版/Food Labeling Management Platform/src/lib/categoryButtonAppearance.ts
... ... @@ -2,7 +2,7 @@
2 2  
3 3 export type AppearanceToken = "TEXT" | "COLOR" | "IMAGE";
4 4  
5   -/** Display Text 输入:仅 trim,无字数上限 */
  5 +/** Display Text 输入:仅 trim 首尾空白,保留中间换行(Enter) */
6 6 export function normalizeDisplayText(s: string | null | undefined): string {
7 7 return String(s ?? "").trim();
8 8 }
... ...
美国版/Food Labeling Management Platform/src/lib/labelFormDatePreview.ts
... ... @@ -369,13 +369,29 @@ export function resolveElementDateTimeDisplay(
369 369 type === "TIME"
370 370 ) {
371 371 const off = readOffsetFromElementConfig(el);
372   - return resolveFromOffsetAmount(el, off?.amount ?? 0, off?.unit ?? "Days", base);
  372 + if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base);
  373 + if (
  374 + rawText &&
  375 + !tryParsePrintInputOffsetStored(rawText) &&
  376 + isLikelyResolvedDateTimeLiteral(rawText)
  377 + ) {
  378 + return applyElementPrefix(cfg, rawText);
  379 + }
  380 + return resolveFromOffsetAmount(el, 0, "Days", base);
373 381 }
374 382 return null;
375 383 }
376 384  
377 385 const off = readOffsetFromElementConfig(el);
378 386 if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base);
  387 + if (
  388 + rawText &&
  389 + !tryParsePrintInputOffsetStored(rawText) &&
  390 + isLikelyResolvedDateTimeLiteral(rawText) &&
  391 + isDateTimeLiveResolveField(el)
  392 + ) {
  393 + return applyElementPrefix(cfg, rawText);
  394 + }
379 395 return resolveFromOffsetAmount(el, 0, "Days", base);
380 396 }
381 397  
... ...
美国版/Food Labeling Management Platform/src/services/labelService.ts
... ... @@ -37,9 +37,23 @@ export function normalizeLabelDto(raw: unknown): LabelDto {
37 37 ? "SPECIFIED"
38 38 : null;
39 39 const regionRaw = r.region ?? r.Region;
  40 + const locationIds = parseIdList(r.locationIds ?? r.LocationIds) ?? [];
  41 + const locationIdRaw = r.locationId ?? r.LocationId;
  42 + const locationId =
  43 + locationIdRaw != null && String(locationIdRaw).trim()
  44 + ? String(locationIdRaw).trim()
  45 + : locationIds[0] ?? null;
  46 + const mergedLocationIds = [
  47 + ...new Set([
  48 + ...(locationId ? [locationId] : []),
  49 + ...locationIds,
  50 + ]),
  51 + ];
40 52 return {
41 53 ...(r as object),
42 54 id: String(r.id ?? r.Id ?? r.labelCode ?? r.LabelCode ?? ""),
  55 + locationId,
  56 + locationIds: mergedLocationIds.length ? mergedLocationIds : locationIds,
43 57 regionIds: mergedRegionIds.length ? mergedRegionIds : regionIds,
44 58 groupIds: mergedRegionIds.length ? mergedRegionIds : groupIds,
45 59 appliedRegionType: appliedRegionType as LabelDto["appliedRegionType"],
... ... @@ -47,6 +61,18 @@ export function normalizeLabelDto(raw: unknown): LabelDto {
47 61 } as LabelDto;
48 62 }
49 63  
  64 +function locationBodyFromInput(input: LabelCreateInput | LabelUpdateInput): Record<string, unknown> {
  65 + const locationIds = [
  66 + ...new Set((input.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean)),
  67 + ];
  68 + const body: Record<string, unknown> = {};
  69 + if (locationIds.length) {
  70 + body.locationIds = locationIds;
  71 + body.locationId = locationIds[0];
  72 + }
  73 + return body;
  74 +}
  75 +
50 76 function scopeBodyFromInput(input: LabelCreateInput | LabelUpdateInput): Record<string, unknown> {
51 77 const regionIds = [
52 78 ...new Set(
... ... @@ -118,7 +144,7 @@ export async function createLabel(input: LabelCreateInput): Promise&lt;LabelDto&gt; {
118 144 labelCode: String(input.labelCode ?? "").trim() || null,
119 145 labelName: input.labelName,
120 146 templateCode: input.templateCode,
121   - locationId: input.locationId,
  147 + ...locationBodyFromInput(input),
122 148 labelCategoryId: input.labelCategoryId,
123 149 labelTypeId: input.labelTypeId,
124 150 productIds: input.productIds,
... ... @@ -137,7 +163,7 @@ export async function updateLabel(labelCode: string, input: LabelUpdateInput): P
137 163 body: {
138 164 labelName: input.labelName,
139 165 templateCode: input.templateCode,
140   - locationId: input.locationId,
  166 + ...locationBodyFromInput(input),
141 167 labelCategoryId: input.labelCategoryId,
142 168 labelTypeId: input.labelTypeId,
143 169 productIds: input.productIds,
... ...
美国版/Food Labeling Management Platform/src/types/label.ts
... ... @@ -4,6 +4,8 @@ export type LabelDto = {
4 4 labelName?: string | null;
5 5 templateCode?: string | null;
6 6 locationId?: string | null;
  7 + /** 详情/编辑:适用门店 Id 列表(与后端 LocationIds 对齐) */
  8 + locationIds?: string[] | null;
7 9 labelCategoryId?: string | null;
8 10 labelTypeId?: string | null;
9 11 productIds?: string[] | null;
... ... @@ -61,7 +63,8 @@ export type LabelCreateInput = {
61 63 labelCode?: string | null;
62 64 labelName: string;
63 65 templateCode: string;
64   - locationId: string;
  66 + /** 适用门店(至少 1 个;Select All 时为当前 Region 下全部门店 Id) */
  67 + locationIds: string[];
65 68 labelCategoryId: string;
66 69 labelTypeId: string;
67 70 productIds: string[]; // 至少 1 个
... ... @@ -75,7 +78,8 @@ export type LabelCreateInput = {
75 78 export type LabelUpdateInput = {
76 79 labelName: string;
77 80 templateCode: string;
78   - locationId: string;
  81 + /** 适用门店(至少 1 个) */
  82 + locationIds: string[];
79 83 labelCategoryId: string;
80 84 labelTypeId: string;
81 85 productIds: string[]; // 至少 1 个
... ...