Commit 9d21893049b40f11372c0a879399537b164c131b
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 () => { |
| 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<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<T>( |
| 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
美国版/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
美国版/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<LabelDto> { |
| 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 个 | ... | ... |