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,6 +367,9 @@ public final class NativeTemplateCommandBuilder { | ||
| 367 | } | 367 | } |
| 368 | 368 | ||
| 369 | private static boolean shouldRasterizeText(String text, String type, JSONObject config) { | 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 | if (getBoolean(config, "forceRasterText", false)) { | 373 | if (getBoolean(config, "forceRasterText", false)) { |
| 371 | return true; | 374 | return true; |
| 372 | } | 375 | } |
| @@ -431,11 +434,13 @@ public final class NativeTemplateCommandBuilder { | @@ -431,11 +434,13 @@ public final class NativeTemplateCommandBuilder { | ||
| 431 | text = text.replaceAll("\\s{2,}", " ").trim(); | 434 | text = text.replaceAll("\\s{2,}", " ").trim(); |
| 432 | } | 435 | } |
| 433 | int contentWidth = Math.max(8, pxToDots(getDouble(element, "width", 0), dpi)); | 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 | Paint paint = new Paint(); | 439 | Paint paint = new Paint(); |
| 435 | paint.setAntiAlias(true); | 440 | paint.setAntiAlias(true); |
| 436 | paint.setDither(true); | 441 | paint.setDither(true); |
| 437 | paint.setSubpixelText(true); | 442 | paint.setSubpixelText(true); |
| 438 | - paint.setColor(Color.BLACK); | 443 | + paint.setColor(inverted ? Color.WHITE : Color.BLACK); |
| 439 | int fontSizeDots = Math.max(14, pxToDots(getDouble(config, "fontSize", 14), dpi)); | 444 | int fontSizeDots = Math.max(14, pxToDots(getDouble(config, "fontSize", 14), dpi)); |
| 440 | paint.setTextSize(fontSizeDots); | 445 | paint.setTextSize(fontSizeDots); |
| 441 | /** 不再对 TEXT_PRICE 强制加粗:fakeBold + 粗体会糊边、measureText 偏窄,右对齐时左侧易出现杂点 */ | 446 | /** 不再对 TEXT_PRICE 强制加粗:fakeBold + 粗体会糊边、measureText 偏窄,右对齐时左侧易出现杂点 */ |
| @@ -467,10 +472,15 @@ public final class NativeTemplateCommandBuilder { | @@ -467,10 +472,15 @@ public final class NativeTemplateCommandBuilder { | ||
| 467 | int horizontalPadding = TEXT_PADDING_DOTS * 2; | 472 | int horizontalPadding = TEXT_PADDING_DOTS * 2; |
| 468 | int verticalPadding = TEXT_PADDING_DOTS * 2; | 473 | int verticalPadding = TEXT_PADDING_DOTS * 2; |
| 469 | int width = ensureMultipleOf8(Math.max(contentWidth + horizontalPadding * 2, (int) Math.ceil(maxLineWidth) + horizontalPadding * 2 + 4)); | 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 | Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); | 476 | Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); |
| 472 | Canvas canvas = new Canvas(bitmap); | 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 | int topOffset = "TEXT_PRICE".equals(type) | 485 | int topOffset = "TEXT_PRICE".equals(type) |
| 476 | ? Math.max(verticalPadding, (height - totalHeight) / 2) | 486 | ? Math.max(verticalPadding, (height - totalHeight) / 2) |
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
| @@ -952,7 +952,7 @@ const goBluetoothPage = () => { | @@ -952,7 +952,7 @@ const goBluetoothPage = () => { | ||
| 952 | 952 | ||
| 953 | .cat-icon-text--wrap, | 953 | .cat-icon-text--wrap, |
| 954 | .cat-icon-text--on-color { | 954 | .cat-icon-text--on-color { |
| 955 | - white-space: normal; | 955 | + white-space: pre-wrap; |
| 956 | word-break: break-word; | 956 | word-break: break-word; |
| 957 | overflow: hidden; | 957 | overflow: hidden; |
| 958 | } | 958 | } |
| @@ -1085,7 +1085,7 @@ const goBluetoothPage = () => { | @@ -1085,7 +1085,7 @@ const goBluetoothPage = () => { | ||
| 1085 | padding: 0; | 1085 | padding: 0; |
| 1086 | max-width: 100%; | 1086 | max-width: 100%; |
| 1087 | max-height: 100%; | 1087 | max-height: 100%; |
| 1088 | - white-space: normal; | 1088 | + white-space: pre-wrap; |
| 1089 | word-break: break-word; | 1089 | word-break: break-word; |
| 1090 | overflow: hidden; | 1090 | overflow: hidden; |
| 1091 | } | 1091 | } |
| @@ -1238,14 +1238,14 @@ const goBluetoothPage = () => { | @@ -1238,14 +1238,14 @@ const goBluetoothPage = () => { | ||
| 1238 | color: #111827; | 1238 | color: #111827; |
| 1239 | line-height: 1.25; | 1239 | line-height: 1.25; |
| 1240 | text-align: center; | 1240 | text-align: center; |
| 1241 | - white-space: normal; | 1241 | + white-space: pre-wrap; |
| 1242 | word-break: break-word; | 1242 | word-break: break-word; |
| 1243 | overflow: hidden; | 1243 | overflow: hidden; |
| 1244 | box-sizing: border-box; | 1244 | box-sizing: border-box; |
| 1245 | } | 1245 | } |
| 1246 | 1246 | ||
| 1247 | .food-thumb-text--wrap { | 1247 | .food-thumb-text--wrap { |
| 1248 | - white-space: normal; | 1248 | + white-space: pre-wrap; |
| 1249 | word-break: break-word; | 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,6 +299,7 @@ import { | ||
| 299 | templateIncludesEmployeeElement, | 299 | templateIncludesEmployeeElement, |
| 300 | } from '../../utils/labelPreview/employeeElement' | 300 | } from '../../utils/labelPreview/employeeElement' |
| 301 | import { applyLiveDateTimeFieldsToTemplate } from '../../utils/labelPreview/printInputOffset' | 301 | import { applyLiveDateTimeFieldsToTemplate } from '../../utils/labelPreview/printInputOffset' |
| 302 | +import { readInvertColors } from '../../utils/invertColorsConfig' | ||
| 302 | import { | 303 | import { |
| 303 | getLabelPrintRasterLayout, | 304 | getLabelPrintRasterLayout, |
| 304 | getPreviewCanvasCssSize, | 305 | getPreviewCanvasCssSize, |
| @@ -672,6 +673,9 @@ function applyNativeTemplateStyleScale( | @@ -672,6 +673,9 @@ function applyNativeTemplateStyleScale( | ||
| 672 | // 时间/日期类右对齐文本在部分机型原生 TEXT 宽度估算有误差,强制位图可避免截断。 | 673 | // 时间/日期类右对齐文本在部分机型原生 TEXT 宽度估算有误差,强制位图可避免截断。 |
| 673 | cfg.forceRasterText = true | 674 | cfg.forceRasterText = true |
| 674 | } | 675 | } |
| 676 | + if (readInvertColors(cfg)) { | ||
| 677 | + cfg.forceRasterText = true | ||
| 678 | + } | ||
| 675 | } | 679 | } |
| 676 | if (type === 'BARCODE') { | 680 | if (type === 'BARCODE') { |
| 677 | const raw = String( | 681 | const raw = String( |
| @@ -824,6 +828,8 @@ const systemTemplate = ref<SystemLabelTemplate | null>(null) | @@ -824,6 +828,8 @@ const systemTemplate = ref<SystemLabelTemplate | null>(null) | ||
| 824 | const basePreviewTemplate = ref<SystemLabelTemplate | null>(null) | 828 | const basePreviewTemplate = ref<SystemLabelTemplate | null>(null) |
| 825 | /** 接口返回的 templateProductDefaults,每次合并/重绘按当前时刻重算日期时间 */ | 829 | /** 接口返回的 templateProductDefaults,每次合并/重绘按当前时刻重算日期时间 */ |
| 826 | const previewProductDefaults = ref<Record<string, string>>({}) | 830 | const previewProductDefaults = ref<Record<string, string>>({}) |
| 831 | +/** 最近一次 8.2 原始响应,便于 defaults 解析失败时重试 */ | ||
| 832 | +const lastPreviewRawPayload = ref<unknown>(null) | ||
| 827 | const printOptionSelections = ref<Record<string, string[]>>({}) | 833 | const printOptionSelections = ref<Record<string, string[]>>({}) |
| 828 | const dictLabelsByElementId = ref<Record<string, string>>({}) | 834 | const dictLabelsByElementId = ref<Record<string, string>>({}) |
| 829 | const dictValuesByElementId = ref<Record<string, string[]>>({}) | 835 | const dictValuesByElementId = ref<Record<string, string[]>>({}) |
| @@ -1166,7 +1172,14 @@ function computeMergedPreviewTemplate(at?: Date): SystemLabelTemplate | null { | @@ -1166,7 +1172,14 @@ function computeMergedPreviewTemplate(at?: Date): SystemLabelTemplate | null { | ||
| 1166 | let base = basePreviewTemplate.value | 1172 | let base = basePreviewTemplate.value |
| 1167 | if (!base) return null | 1173 | if (!base) return null |
| 1168 | const baseTime = at ?? new Date() | 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 | if (Object.keys(defaults).length > 0) { | 1183 | if (Object.keys(defaults).length > 0) { |
| 1171 | base = applyTemplateProductDefaultValuesToTemplate(base, defaults, baseTime) | 1184 | base = applyTemplateProductDefaultValuesToTemplate(base, defaults, baseTime) |
| 1172 | } | 1185 | } |
| @@ -1332,6 +1345,7 @@ async function loadPreview() { | @@ -1332,6 +1345,7 @@ async function loadPreview() { | ||
| 1332 | systemTemplate.value = null | 1345 | systemTemplate.value = null |
| 1333 | basePreviewTemplate.value = null | 1346 | basePreviewTemplate.value = null |
| 1334 | previewProductDefaults.value = {} | 1347 | previewProductDefaults.value = {} |
| 1348 | + lastPreviewRawPayload.value = null | ||
| 1335 | printOptionSelections.value = {} | 1349 | printOptionSelections.value = {} |
| 1336 | printFreeFieldValues.value = {} | 1350 | printFreeFieldValues.value = {} |
| 1337 | dictLabelsByElementId.value = {} | 1351 | dictLabelsByElementId.value = {} |
| @@ -1344,6 +1358,7 @@ async function loadPreview() { | @@ -1344,6 +1358,7 @@ async function loadPreview() { | ||
| 1344 | productId: productId.value || undefined, | 1358 | productId: productId.value || undefined, |
| 1345 | baseTime: new Date().toISOString(), | 1359 | baseTime: new Date().toISOString(), |
| 1346 | }) | 1360 | }) |
| 1361 | + lastPreviewRawPayload.value = raw | ||
| 1347 | const root = raw as Record<string, unknown> | 1362 | const root = raw as Record<string, unknown> |
| 1348 | const nested = root.data ?? root.Data | 1363 | const nested = root.data ?? root.Data |
| 1349 | const inner = | 1364 | const inner = |
| @@ -1410,19 +1425,13 @@ async function loadPreview() { | @@ -1410,19 +1425,13 @@ async function loadPreview() { | ||
| 1410 | } | 1425 | } |
| 1411 | const productDefaults = extractTemplateProductDefaultValuesFromPreviewPayload(raw) | 1426 | const productDefaults = extractTemplateProductDefaultValuesFromPreviewPayload(raw) |
| 1412 | previewProductDefaults.value = productDefaults | 1427 | previewProductDefaults.value = productDefaults |
| 1413 | - let tmplWithDefaults = | ||
| 1414 | - Object.keys(productDefaults).length > 0 | ||
| 1415 | - ? applyTemplateProductDefaultValuesToTemplate(tmplRaw, productDefaults) | ||
| 1416 | - : tmplRaw | 1428 | + let tmplBase = tmplRaw |
| 1417 | const productCodeValue = extractProductCodeValueFromPreviewPayload(raw) | 1429 | const productCodeValue = extractProductCodeValueFromPreviewPayload(raw) |
| 1418 | if (productCodeValue) { | 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 | basePreviewTemplate.value = base | 1435 | basePreviewTemplate.value = base |
| 1427 | printOptionSelections.value = readSelectionsFromTemplate(base) | 1436 | printOptionSelections.value = readSelectionsFromTemplate(base) |
| 1428 | printFreeFieldValues.value = ensureFreeFieldKeys(base, readFreeFieldValuesFromTemplate(base)) | 1437 | printFreeFieldValues.value = ensureFreeFieldKeys(base, readFreeFieldValuesFromTemplate(base)) |
| @@ -1626,7 +1635,7 @@ const handlePrint = async () => { | @@ -1626,7 +1635,7 @@ const handlePrint = async () => { | ||
| 1626 | * Virtual BT 不做 xScale/安全区收窄(易导致营养表错位、条码丢失);光栅仅作回退。 | 1635 | * Virtual BT 不做 xScale/安全区收窄(易导致营养表错位、条码丢失);光栅仅作回退。 |
| 1627 | * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。 | 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 | const currentDriver = getCurrentPrinterDriver() | 1639 | const currentDriver = getCurrentPrinterDriver() |
| 1631 | const currentConn = getBluetoothConnection() | 1640 | const currentConn = getBluetoothConnection() |
| 1632 | const isD320faxClassicNative = | 1641 | const isD320faxClassicNative = |
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
| @@ -128,13 +128,12 @@ export async function fetchUsAppLabelingTree(input: { | @@ -128,13 +128,12 @@ export async function fetchUsAppLabelingTree(input: { | ||
| 128 | }) | 128 | }) |
| 129 | } | 129 | } |
| 130 | 130 | ||
| 131 | +/** 离线缓存键:仅 location + labelCode + productId(不含 baseTime,避免同步预拉与预览页 key 不一致) */ | ||
| 131 | export function buildLabelPreviewCacheKey(body: UsAppLabelPreviewInputVo): string { | 132 | export function buildLabelPreviewCacheKey(body: UsAppLabelPreviewInputVo): string { |
| 132 | const loc = String(body.locationId || '').trim() | 133 | const loc = String(body.locationId || '').trim() |
| 133 | const code = String(body.labelCode || '').trim() | 134 | const code = String(body.labelCode || '').trim() |
| 134 | const pid = String(body.productId || '').trim() | 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 | /** 接口 8.2(在线写入 SQLite,离线读缓存) */ | 139 | /** 接口 8.2(在线写入 SQLite,离线读缓存) */ |
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
| 1 | import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' | 1 | import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' |
| 2 | import { | 2 | import { |
| 3 | applyLiveDateTimeFieldsToTemplate, | 3 | applyLiveDateTimeFieldsToTemplate, |
| 4 | + isUniAppDateTimeOffsetField, | ||
| 4 | resolveTemplateDefaultValueForElement, | 5 | resolveTemplateDefaultValueForElement, |
| 6 | + tryParsePrintInputOffsetStored, | ||
| 5 | } from './printInputOffset' | 7 | } from './printInputOffset' |
| 6 | import { applyNutritionDefaultJsonToConfig } from './nutritionDefaultsMerge' | 8 | import { applyNutritionDefaultJsonToConfig } from './nutritionDefaultsMerge' |
| 7 | 9 | ||
| @@ -168,6 +170,41 @@ export function applyLabelSizeTextToTemplate( | @@ -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 | * 从预览接口响应中取出 `templateProductDefaultValues`(elementId → 字符串,兼容 PascalCase / 嵌套 data)。 | 208 | * 从预览接口响应中取出 `templateProductDefaultValues`(elementId → 字符串,兼容 PascalCase / 嵌套 data)。 |
| 172 | */ | 209 | */ |
| 173 | export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: unknown): Record<string, string> { | 210 | export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: unknown): Record<string, string> { |
| @@ -180,10 +217,7 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u | @@ -180,10 +217,7 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u | ||
| 180 | for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { | 217 | for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { |
| 181 | const key = String(k).trim() | 218 | const key = String(k).trim() |
| 182 | if (!key) continue | 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 | return Object.keys(out).length ? out : null | 222 | return Object.keys(out).length ? out : null |
| 189 | } | 223 | } |
| @@ -236,9 +270,32 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u | @@ -236,9 +270,32 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u | ||
| 236 | inner ? tryLayer(inner) : null, | 270 | inner ? tryLayer(inner) : null, |
| 237 | tryLayer(r), | 271 | tryLayer(r), |
| 238 | tryLayer(payload), | 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 | function isTemplateSectionScanElement(el: SystemTemplateElementBase): boolean { | 299 | function isTemplateSectionScanElement(el: SystemTemplateElementBase): boolean { |
| 243 | const cfg = el.config || {} | 300 | const cfg = el.config || {} |
| 244 | const typeAdd = String( | 301 | const typeAdd = String( |
| @@ -313,6 +370,30 @@ export function applyProductCodeValueToTemplateScanElements( | @@ -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 | * 将平台录入的默认值合并进模板元素 config,供画布预览(键与 elements[].id 一致)。 | 397 | * 将平台录入的默认值合并进模板元素 config,供画布预览(键与 elements[].id 一致)。 |
| 317 | */ | 398 | */ |
| 318 | export function applyTemplateProductDefaultValuesToTemplate( | 399 | export function applyTemplateProductDefaultValuesToTemplate( |
| @@ -323,9 +404,7 @@ export function applyTemplateProductDefaultValuesToTemplate( | @@ -323,9 +404,7 @@ export function applyTemplateProductDefaultValuesToTemplate( | ||
| 323 | const keys = Object.keys(defaults) | 404 | const keys = Object.keys(defaults) |
| 324 | if (!keys.length) return template | 405 | if (!keys.length) return template |
| 325 | const elements = (template.elements || []).map((el) => { | 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 | if (v === undefined) return el | 408 | if (v === undefined) return el |
| 330 | const type = String(el.type || '').toUpperCase() | 409 | const type = String(el.type || '').toUpperCase() |
| 331 | const cfg = { ...(el.config || {}) } as Record<string, any> | 410 | const cfg = { ...(el.config || {}) } as Record<string, any> |
| @@ -359,9 +438,15 @@ export function applyTemplateProductDefaultValuesToTemplate( | @@ -359,9 +438,15 @@ export function applyTemplateProductDefaultValuesToTemplate( | ||
| 359 | } | 438 | } |
| 360 | 439 | ||
| 361 | if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { | 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 | return { ...el, config: cfg } | 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,13 +224,46 @@ export function resolveElementDateTimeDisplay ( | ||
| 224 | type === 'TIME' | 224 | type === 'TIME' |
| 225 | ) { | 225 | ) { |
| 226 | const off = readOffsetFromElementConfig(el) | 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 | return null | 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 | const off = readOffsetFromElementConfig(el) | 257 | const off = readOffsetFromElementConfig(el) |
| 233 | if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base) | 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 | return resolveFromOffsetAmount(el, 0, 'Days', base) | 267 | return resolveFromOffsetAmount(el, 0, 'Days', base) |
| 235 | } | 268 | } |
| 236 | 269 |
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
| 1 | import type { RawImageDataSource, SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' | 1 | import type { RawImageDataSource, SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' |
| 2 | import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl' | 2 | import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl' |
| 3 | import { sortElementsForPreview, normalizeTemplatePrintOrientation } from './normalizePreviewTemplate' | 3 | import { sortElementsForPreview, normalizeTemplatePrintOrientation } from './normalizePreviewTemplate' |
| 4 | -import { resolveElementDateTimeDisplay } from './printInputOffset' | 4 | +import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral } from './printInputOffset' |
| 5 | import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement' | 5 | import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement' |
| 6 | import QRCode from 'qrcode' | 6 | import QRCode from 'qrcode' |
| 7 | import { readInvertColors } from '../invertColorsConfig' | 7 | import { readInvertColors } from '../invertColorsConfig' |
| @@ -134,6 +134,11 @@ function previewTextForElement(element: SystemTemplateElementBase, baseTime: Dat | @@ -134,6 +134,11 @@ function previewTextForElement(element: SystemTemplateElementBase, baseTime: Dat | ||
| 134 | return applyConfigPrefix(config, getLoggedInEmployeeDisplayName()) | 134 | return applyConfigPrefix(config, getLoggedInEmployeeDisplayName()) |
| 135 | } | 135 | } |
| 136 | if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { | 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 | const live = resolveElementDateTimeDisplay(element, baseTime) | 142 | const live = resolveElementDateTimeDisplay(element, baseTime) |
| 138 | if (live != null && live.trim()) return live | 143 | if (live != null && live.trim()) return live |
| 139 | } | 144 | } |
| @@ -661,10 +666,15 @@ function runLabelPreviewCanvasDraw( | @@ -661,10 +666,15 @@ function runLabelPreviewCanvasDraw( | ||
| 661 | else if (align === 'right') tx = x + w - pad | 666 | else if (align === 'right') tx = x + w - pad |
| 662 | ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left') | 667 | ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left') |
| 663 | const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15)) | 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 | const startY = y + pad + fontSize | 678 | const startY = y + pad + fontSize |
| 669 | lines.slice(0, maxLines).forEach((ln, li) => { | 679 | lines.slice(0, maxLines).forEach((ln, li) => { |
| 670 | ctx.fillText(ln, tx, startY + li * lineHeight) | 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,6 +10,7 @@ import type { | ||
| 10 | import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' | 10 | import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' |
| 11 | import { applyTemplateData } from './templateRenderer' | 11 | import { applyTemplateData } from './templateRenderer' |
| 12 | import { resolveElementDateTimeDisplay } from '../labelPreview/printInputOffset' | 12 | import { resolveElementDateTimeDisplay } from '../labelPreview/printInputOffset' |
| 13 | +import { readInvertColors } from '../invertColorsConfig' | ||
| 13 | 14 | ||
| 14 | function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { | 15 | function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { |
| 15 | const type = String(el.type || '').toUpperCase() | 16 | const type = String(el.type || '').toUpperCase() |
| @@ -95,9 +96,10 @@ function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): Sy | @@ -95,9 +96,10 @@ function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): Sy | ||
| 95 | */ | 96 | */ |
| 96 | export function normalizeTemplateForNativeFastJob ( | 97 | export function normalizeTemplateForNativeFastJob ( |
| 97 | template: SystemLabelTemplate, | 98 | template: SystemLabelTemplate, |
| 98 | - data: LabelTemplateData | 99 | + data: LabelTemplateData, |
| 100 | + baseTime: Date = new Date(), | ||
| 99 | ): SystemLabelTemplate { | 101 | ): SystemLabelTemplate { |
| 100 | - const now = new Date() | 102 | + const now = baseTime |
| 101 | 103 | ||
| 102 | const extras: SystemTemplateElementBase[] = [] | 104 | const extras: SystemTemplateElementBase[] = [] |
| 103 | const elements = (template.elements || []).map((el) => { | 105 | const elements = (template.elements || []).map((el) => { |
| @@ -125,10 +127,14 @@ export function normalizeTemplateForNativeFastJob ( | @@ -125,10 +127,14 @@ export function normalizeTemplateForNativeFastJob ( | ||
| 125 | if (v && u && !v.endsWith(u)) text = `${v}${u}` | 127 | if (v && u && !v.endsWith(u)) text = `${v}${u}` |
| 126 | else text = v || u | 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 | return { | 134 | return { |
| 129 | ...el, | 135 | ...el, |
| 130 | type: 'TEXT_STATIC' as typeof el.type, | 136 | type: 'TEXT_STATIC' as typeof el.type, |
| 131 | - config: { ...config, text, nativeSourceType: type }, | 137 | + config: nextConfig, |
| 132 | } | 138 | } |
| 133 | } | 139 | } |
| 134 | if (type === 'NUTRITION') { | 140 | if (type === 'NUTRITION') { |
| @@ -164,7 +170,14 @@ export function normalizeTemplateForNativeFastJob ( | @@ -164,7 +170,14 @@ export function normalizeTemplateForNativeFastJob ( | ||
| 164 | } | 170 | } |
| 165 | return el | 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 | return { ...template, elements: merged } | 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,6 +207,28 @@ export async function hasOfflineCache(module: string, name: string): Promise<boo | ||
| 207 | return rows.length > 0 | 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 | export async function getOfflineCache<T = unknown>(module: string, name: string): Promise<T | null> { | 232 | export async function getOfflineCache<T = unknown>(module: string, name: string): Promise<T | null> { |
| 211 | const key = cachePk(module, name) | 233 | const key = cachePk(module, name) |
| 212 | if (!isAppSqliteAvailable()) { | 234 | if (!isAppSqliteAvailable()) { |
| @@ -399,6 +421,14 @@ export async function fetchWithOfflineCache<T>( | @@ -399,6 +421,14 @@ export async function fetchWithOfflineCache<T>( | ||
| 399 | if (await hasOfflineCache(module, name)) { | 421 | if (await hasOfflineCache(module, name)) { |
| 400 | return (await getOfflineCache<T>(module, name)) as T | 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 | throw new Error('No offline cache available') | 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,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 | return new UsAppLabelPreviewDto | 471 | return new UsAppLabelPreviewDto |
| 467 | { | 472 | { |
| 468 | LabelId = labelRow.Id, | 473 | LabelId = labelRow.Id, |
| @@ -1177,6 +1182,177 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ | @@ -1177,6 +1182,177 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ | ||
| 1177 | return Math.Round((current - previous) * 100m / previous, 2); | 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 | private async Task<string?> ResolveTemplateProductDefaultValuesJsonAsync( | 1356 | private async Task<string?> ResolveTemplateProductDefaultValuesJsonAsync( |
| 1181 | string templateId, | 1357 | string templateId, |
| 1182 | string? productId, | 1358 | string? productId, |
美国版/Food Labeling Management Platform/.env.local
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
| @@ -8,6 +8,7 @@ import { | @@ -8,6 +8,7 @@ import { | ||
| 8 | TableRow, | 8 | TableRow, |
| 9 | } from "../ui/table"; | 9 | } from "../ui/table"; |
| 10 | import { Input } from "../ui/input"; | 10 | import { Input } from "../ui/input"; |
| 11 | +import { Textarea } from "../ui/textarea"; | ||
| 11 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| 12 | import { | 13 | import { |
| 13 | Select, | 14 | Select, |
| @@ -1095,14 +1096,14 @@ function CreateLabelCategoryDialog({ | @@ -1095,14 +1096,14 @@ function CreateLabelCategoryDialog({ | ||
| 1095 | {apSel.text && !apSel.image ? ( | 1096 | {apSel.text && !apSel.image ? ( |
| 1096 | <div className="space-y-2"> | 1097 | <div className="space-y-2"> |
| 1097 | <Label>Display Text</Label> | 1098 | <Label>Display Text</Label> |
| 1098 | - <Input | ||
| 1099 | - className="h-10" | 1099 | + <Textarea |
| 1100 | + className="min-h-[4.5rem] resize-y" | ||
| 1100 | placeholder="Category Name" | 1101 | placeholder="Category Name" |
| 1101 | value={displayTextForPhoto} | 1102 | value={displayTextForPhoto} |
| 1102 | onChange={(e) => setDisplayTextForPhoto(e.target.value)} | 1103 | onChange={(e) => setDisplayTextForPhoto(e.target.value)} |
| 1103 | /> | 1104 | /> |
| 1104 | <div className="text-xs text-gray-500"> | 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 | </div> | 1107 | </div> |
| 1107 | </div> | 1108 | </div> |
| 1108 | ) : null} | 1109 | ) : null} |
| @@ -1566,14 +1567,14 @@ function EditLabelCategoryDialog({ | @@ -1566,14 +1567,14 @@ function EditLabelCategoryDialog({ | ||
| 1566 | {apSel.text && !apSel.image ? ( | 1567 | {apSel.text && !apSel.image ? ( |
| 1567 | <div className="space-y-2"> | 1568 | <div className="space-y-2"> |
| 1568 | <Label>Display Text</Label> | 1569 | <Label>Display Text</Label> |
| 1569 | - <Input | ||
| 1570 | - className="h-10" | 1570 | + <Textarea |
| 1571 | + className="min-h-[4.5rem] resize-y" | ||
| 1571 | placeholder="Category Name" | 1572 | placeholder="Category Name" |
| 1572 | value={displayTextForPhoto} | 1573 | value={displayTextForPhoto} |
| 1573 | onChange={(e) => setDisplayTextForPhoto(e.target.value)} | 1574 | onChange={(e) => setDisplayTextForPhoto(e.target.value)} |
| 1574 | /> | 1575 | /> |
| 1575 | <div className="text-xs text-gray-500"> | 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 | </div> | 1578 | </div> |
| 1578 | </div> | 1579 | </div> |
| 1579 | ) : null} | 1580 | ) : null} |
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
| @@ -110,6 +110,7 @@ import { | @@ -110,6 +110,7 @@ import { | ||
| 110 | regionNamesToGroupIds, | 110 | regionNamesToGroupIds, |
| 111 | regionOptionsForPartner, | 111 | regionOptionsForPartner, |
| 112 | scopePartnerValidationMessage, | 112 | scopePartnerValidationMessage, |
| 113 | + buildSpecifiedLocationPayload, | ||
| 113 | } from "../../lib/categoryScopeForm"; | 114 | } from "../../lib/categoryScopeForm"; |
| 114 | 115 | ||
| 115 | function toDisplay(v: string | null | undefined): string { | 116 | function toDisplay(v: string | null | undefined): string { |
| @@ -308,7 +309,12 @@ function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput { | @@ -308,7 +309,12 @@ function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput { | ||
| 308 | return { | 309 | return { |
| 309 | labelName: d.labelName ?? "", | 310 | labelName: d.labelName ?? "", |
| 310 | templateCode: d.templateCode ?? "", | 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 | labelCategoryId: d.labelCategoryId ?? "", | 318 | labelCategoryId: d.labelCategoryId ?? "", |
| 313 | labelTypeId: d.labelTypeId ?? "", | 319 | labelTypeId: d.labelTypeId ?? "", |
| 314 | /** 编辑表单仅支持单商品:多商品时取第一个 */ | 320 | /** 编辑表单仅支持单商品:多商品时取第一个 */ |
| @@ -1762,7 +1768,7 @@ function CreateLabelDialog({ | @@ -1762,7 +1768,7 @@ function CreateLabelDialog({ | ||
| 1762 | const [form, setForm] = useState<LabelCreateInput>({ | 1768 | const [form, setForm] = useState<LabelCreateInput>({ |
| 1763 | labelName: "", | 1769 | labelName: "", |
| 1764 | templateCode: "", | 1770 | templateCode: "", |
| 1765 | - locationId: "", | 1771 | + locationIds: [], |
| 1766 | labelCategoryId: "", | 1772 | labelCategoryId: "", |
| 1767 | labelTypeId: "", | 1773 | labelTypeId: "", |
| 1768 | productIds: [], | 1774 | productIds: [], |
| @@ -1774,7 +1780,7 @@ function CreateLabelDialog({ | @@ -1774,7 +1780,7 @@ function CreateLabelDialog({ | ||
| 1774 | setForm({ | 1780 | setForm({ |
| 1775 | labelName: "", | 1781 | labelName: "", |
| 1776 | templateCode: "", | 1782 | templateCode: "", |
| 1777 | - locationId: "", | 1783 | + locationIds: [], |
| 1778 | labelCategoryId: "", | 1784 | labelCategoryId: "", |
| 1779 | labelTypeId: "", | 1785 | labelTypeId: "", |
| 1780 | productIds: [], | 1786 | productIds: [], |
| @@ -1840,7 +1846,7 @@ function CreateLabelDialog({ | @@ -1840,7 +1846,7 @@ function CreateLabelDialog({ | ||
| 1840 | setForm((p) => ({ | 1846 | setForm((p) => ({ |
| 1841 | ...p, | 1847 | ...p, |
| 1842 | templateCode: "", | 1848 | templateCode: "", |
| 1843 | - locationId: "", | 1849 | + locationIds: [], |
| 1844 | labelCategoryId: "", | 1850 | labelCategoryId: "", |
| 1845 | labelTypeId: "", | 1851 | labelTypeId: "", |
| 1846 | productIds: [], | 1852 | productIds: [], |
| @@ -1858,13 +1864,23 @@ function CreateLabelDialog({ | @@ -1858,13 +1864,23 @@ function CreateLabelDialog({ | ||
| 1858 | setForm((p) => ({ | 1864 | setForm((p) => ({ |
| 1859 | ...p, | 1865 | ...p, |
| 1860 | templateCode: "", | 1866 | templateCode: "", |
| 1861 | - locationId: "", | 1867 | + locationIds: [], |
| 1862 | labelCategoryId: "", | 1868 | labelCategoryId: "", |
| 1863 | labelTypeId: "", | 1869 | labelTypeId: "", |
| 1864 | productIds: [], | 1870 | productIds: [], |
| 1865 | })); | 1871 | })); |
| 1866 | }, [open, scopePartnerId]); | 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 | const companySelectOptionsForCreate = useMemo( | 1884 | const companySelectOptionsForCreate = useMemo( |
| 1869 | () => | 1885 | () => |
| 1870 | [...partners] | 1886 | [...partners] |
| @@ -2035,9 +2051,21 @@ function CreateLabelDialog({ | @@ -2035,9 +2051,21 @@ function CreateLabelDialog({ | ||
| 2035 | }); | 2051 | }); |
| 2036 | return; | 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 | toast.error("Validation failed", { | 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 | return; | 2070 | return; |
| 2043 | } | 2071 | } |
| @@ -2118,6 +2146,7 @@ function CreateLabelDialog({ | @@ -2118,6 +2146,7 @@ function CreateLabelDialog({ | ||
| 2118 | try { | 2146 | try { |
| 2119 | await createLabel({ | 2147 | await createLabel({ |
| 2120 | ...form, | 2148 | ...form, |
| 2149 | + locationIds: locPayload.locationIds, | ||
| 2121 | appliedRegionType: regionPayload.appliedRegionType, | 2150 | appliedRegionType: regionPayload.appliedRegionType, |
| 2122 | regionIds: regionPayload.regionIds, | 2151 | regionIds: regionPayload.regionIds, |
| 2123 | groupIds: regionPayload.regionIds, | 2152 | groupIds: regionPayload.regionIds, |
| @@ -2415,15 +2444,21 @@ function CreateLabelDialog({ | @@ -2415,15 +2444,21 @@ function CreateLabelDialog({ | ||
| 2415 | </div> | 2444 | </div> |
| 2416 | <div className="space-y-2"> | 2445 | <div className="space-y-2"> |
| 2417 | <Label>Location *</Label> | 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 | options={locationOptions} | 2450 | options={locationOptions} |
| 2422 | - placeholder={regionScopeReady ? "Select location" : scopeGateHint} | 2451 | + placeholder={regionScopeReady ? "Select location(s)…" : scopeGateHint} |
| 2423 | searchPlaceholder="Search location…" | 2452 | searchPlaceholder="Search location…" |
| 2453 | + selectAllRowLabel="Select All" | ||
| 2424 | emptyText={regionScopeReady ? "No locations in this region." : scopeGateEmpty} | 2454 | emptyText={regionScopeReady ? "No locations in this region." : scopeGateEmpty} |
| 2425 | disabled={scopeBootstrapLoading || !regionScopeReady || scopedOptionsLoading} | 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 | </div> | 2462 | </div> |
| 2428 | <div className="space-y-2"> | 2463 | <div className="space-y-2"> |
| 2429 | <Label>Label Category *</Label> | 2464 | <Label>Label Category *</Label> |
| @@ -2672,7 +2707,7 @@ function EditLabelDialog({ | @@ -2672,7 +2707,7 @@ function EditLabelDialog({ | ||
| 2672 | const [form, setForm] = useState<LabelUpdateInput>({ | 2707 | const [form, setForm] = useState<LabelUpdateInput>({ |
| 2673 | labelName: "", | 2708 | labelName: "", |
| 2674 | templateCode: "", | 2709 | templateCode: "", |
| 2675 | - locationId: "", | 2710 | + locationIds: [], |
| 2676 | labelCategoryId: "", | 2711 | labelCategoryId: "", |
| 2677 | labelTypeId: "", | 2712 | labelTypeId: "", |
| 2678 | productIds: [], | 2713 | productIds: [], |
| @@ -2988,9 +3023,21 @@ function EditLabelDialog({ | @@ -2988,9 +3023,21 @@ function EditLabelDialog({ | ||
| 2988 | }); | 3023 | }); |
| 2989 | return; | 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 | toast.error("Validation failed", { | 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 | return; | 3042 | return; |
| 2996 | } | 3043 | } |
| @@ -3071,6 +3118,7 @@ function EditLabelDialog({ | @@ -3071,6 +3118,7 @@ function EditLabelDialog({ | ||
| 3071 | try { | 3118 | try { |
| 3072 | await updateLabel(label.id, { | 3119 | await updateLabel(label.id, { |
| 3073 | ...form, | 3120 | ...form, |
| 3121 | + locationIds: locPayload.locationIds, | ||
| 3074 | appliedRegionType: regionPayload.appliedRegionType, | 3122 | appliedRegionType: regionPayload.appliedRegionType, |
| 3075 | regionIds: regionPayload.regionIds, | 3123 | regionIds: regionPayload.regionIds, |
| 3076 | groupIds: regionPayload.regionIds, | 3124 | groupIds: regionPayload.regionIds, |
| @@ -3116,12 +3164,20 @@ function EditLabelDialog({ | @@ -3116,12 +3164,20 @@ function EditLabelDialog({ | ||
| 3116 | value: loc.id, | 3164 | value: loc.id, |
| 3117 | label: toDisplay(loc.locationName ?? loc.locationCode ?? loc.id), | 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 | const editCategoryOptions = useMemo(() => { | 3182 | const editCategoryOptions = useMemo(() => { |
| 3127 | const base = labelCategoriesScopedEdit.map((c) => ({ | 3183 | const base = labelCategoriesScopedEdit.map((c) => ({ |
| @@ -3351,17 +3407,23 @@ function EditLabelDialog({ | @@ -3351,17 +3407,23 @@ function EditLabelDialog({ | ||
| 3351 | </div> | 3407 | </div> |
| 3352 | <div className="space-y-2"> | 3408 | <div className="space-y-2"> |
| 3353 | <Label>Location *</Label> | 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 | options={editLocationOptions} | 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 | searchPlaceholder="Search location…" | 3415 | searchPlaceholder="Search location…" |
| 3416 | + selectAllRowLabel="Select All" | ||
| 3360 | emptyText={ | 3417 | emptyText={ |
| 3361 | editScopeReady ? "No locations in selected regions." : "Select applicable region(s) first." | 3418 | editScopeReady ? "No locations in selected regions." : "Select applicable region(s) first." |
| 3362 | } | 3419 | } |
| 3363 | disabled={refLoading || detailLoading || !editScopeReady || editScopedLoading} | 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 | </div> | 3427 | </div> |
| 3366 | </div> | 3428 | </div> |
| 3367 | 3429 |
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
| @@ -13,6 +13,7 @@ import { | @@ -13,6 +13,7 @@ import { | ||
| 13 | import { Button } from "../ui/button"; | 13 | import { Button } from "../ui/button"; |
| 14 | import { Checkbox } from "../ui/checkbox"; | 14 | import { Checkbox } from "../ui/checkbox"; |
| 15 | import { Input } from "../ui/input"; | 15 | import { Input } from "../ui/input"; |
| 16 | +import { Textarea } from "../ui/textarea"; | ||
| 16 | import { | 17 | import { |
| 17 | Table, | 18 | Table, |
| 18 | TableBody, | 19 | TableBody, |
| @@ -1917,14 +1918,14 @@ function ProductFormDialog({ | @@ -1917,14 +1918,14 @@ function ProductFormDialog({ | ||
| 1917 | {apSel.text && !apSel.image ? ( | 1918 | {apSel.text && !apSel.image ? ( |
| 1918 | <div className="space-y-2"> | 1919 | <div className="space-y-2"> |
| 1919 | <Label>Display Text</Label> | 1920 | <Label>Display Text</Label> |
| 1920 | - <Input | ||
| 1921 | - className="h-10" | 1921 | + <Textarea |
| 1922 | + className="min-h-[4.5rem] resize-y" | ||
| 1922 | value={displayText} | 1923 | value={displayText} |
| 1923 | onChange={(e) => setDisplayText(e.target.value)} | 1924 | onChange={(e) => setDisplayText(e.target.value)} |
| 1924 | placeholder="Product Name" | 1925 | placeholder="Product Name" |
| 1925 | /> | 1926 | /> |
| 1926 | <div className="text-xs text-gray-500"> | 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 | </div> | 1929 | </div> |
| 1929 | </div> | 1930 | </div> |
| 1930 | ) : null} | 1931 | ) : null} |
| @@ -2480,14 +2481,14 @@ function ProductCategoryFormDialog({ | @@ -2480,14 +2481,14 @@ function ProductCategoryFormDialog({ | ||
| 2480 | {apSel.text && !apSel.image ? ( | 2481 | {apSel.text && !apSel.image ? ( |
| 2481 | <div className="space-y-2"> | 2482 | <div className="space-y-2"> |
| 2482 | <Label>Display Text</Label> | 2483 | <Label>Display Text</Label> |
| 2483 | - <Input | ||
| 2484 | - className="h-10" | 2484 | + <Textarea |
| 2485 | + className="min-h-[4.5rem] resize-y" | ||
| 2485 | value={displayText} | 2486 | value={displayText} |
| 2486 | onChange={(e) => setDisplayText(e.target.value)} | 2487 | onChange={(e) => setDisplayText(e.target.value)} |
| 2487 | placeholder="Category Name" | 2488 | placeholder="Category Name" |
| 2488 | /> | 2489 | /> |
| 2489 | <div className="text-xs text-gray-500"> | 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 | </div> | 2492 | </div> |
| 2492 | </div> | 2493 | </div> |
| 2493 | ) : null} | 2494 | ) : null} |
美国版/Food Labeling Management Platform/src/components/ui/category-button-visual-thumb.tsx
| @@ -16,7 +16,7 @@ const TEXT_WRAP_STYLE: CSSProperties = { | @@ -16,7 +16,7 @@ const TEXT_WRAP_STYLE: CSSProperties = { | ||
| 16 | width: "100%", | 16 | width: "100%", |
| 17 | maxWidth: "100%", | 17 | maxWidth: "100%", |
| 18 | minWidth: 0, | 18 | minWidth: 0, |
| 19 | - whiteSpace: "normal", | 19 | + whiteSpace: "pre-wrap", |
| 20 | wordBreak: "break-word", | 20 | wordBreak: "break-word", |
| 21 | overflow: "hidden", | 21 | overflow: "hidden", |
| 22 | textAlign: "center", | 22 | textAlign: "center", |
美国版/Food Labeling Management Platform/src/lib/categoryButtonAppearance.ts
| @@ -2,7 +2,7 @@ | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | ||
| 3 | export type AppearanceToken = "TEXT" | "COLOR" | "IMAGE"; | 3 | export type AppearanceToken = "TEXT" | "COLOR" | "IMAGE"; |
| 4 | 4 | ||
| 5 | -/** Display Text 输入:仅 trim,无字数上限 */ | 5 | +/** Display Text 输入:仅 trim 首尾空白,保留中间换行(Enter) */ |
| 6 | export function normalizeDisplayText(s: string | null | undefined): string { | 6 | export function normalizeDisplayText(s: string | null | undefined): string { |
| 7 | return String(s ?? "").trim(); | 7 | return String(s ?? "").trim(); |
| 8 | } | 8 | } |
美国版/Food Labeling Management Platform/src/lib/labelFormDatePreview.ts
| @@ -369,13 +369,29 @@ export function resolveElementDateTimeDisplay( | @@ -369,13 +369,29 @@ export function resolveElementDateTimeDisplay( | ||
| 369 | type === "TIME" | 369 | type === "TIME" |
| 370 | ) { | 370 | ) { |
| 371 | const off = readOffsetFromElementConfig(el); | 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 | return null; | 382 | return null; |
| 375 | } | 383 | } |
| 376 | 384 | ||
| 377 | const off = readOffsetFromElementConfig(el); | 385 | const off = readOffsetFromElementConfig(el); |
| 378 | if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base); | 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 | return resolveFromOffsetAmount(el, 0, "Days", base); | 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,9 +37,23 @@ export function normalizeLabelDto(raw: unknown): LabelDto { | ||
| 37 | ? "SPECIFIED" | 37 | ? "SPECIFIED" |
| 38 | : null; | 38 | : null; |
| 39 | const regionRaw = r.region ?? r.Region; | 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 | return { | 52 | return { |
| 41 | ...(r as object), | 53 | ...(r as object), |
| 42 | id: String(r.id ?? r.Id ?? r.labelCode ?? r.LabelCode ?? ""), | 54 | id: String(r.id ?? r.Id ?? r.labelCode ?? r.LabelCode ?? ""), |
| 55 | + locationId, | ||
| 56 | + locationIds: mergedLocationIds.length ? mergedLocationIds : locationIds, | ||
| 43 | regionIds: mergedRegionIds.length ? mergedRegionIds : regionIds, | 57 | regionIds: mergedRegionIds.length ? mergedRegionIds : regionIds, |
| 44 | groupIds: mergedRegionIds.length ? mergedRegionIds : groupIds, | 58 | groupIds: mergedRegionIds.length ? mergedRegionIds : groupIds, |
| 45 | appliedRegionType: appliedRegionType as LabelDto["appliedRegionType"], | 59 | appliedRegionType: appliedRegionType as LabelDto["appliedRegionType"], |
| @@ -47,6 +61,18 @@ export function normalizeLabelDto(raw: unknown): LabelDto { | @@ -47,6 +61,18 @@ export function normalizeLabelDto(raw: unknown): LabelDto { | ||
| 47 | } as LabelDto; | 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 | function scopeBodyFromInput(input: LabelCreateInput | LabelUpdateInput): Record<string, unknown> { | 76 | function scopeBodyFromInput(input: LabelCreateInput | LabelUpdateInput): Record<string, unknown> { |
| 51 | const regionIds = [ | 77 | const regionIds = [ |
| 52 | ...new Set( | 78 | ...new Set( |
| @@ -118,7 +144,7 @@ export async function createLabel(input: LabelCreateInput): Promise<LabelDto> { | @@ -118,7 +144,7 @@ export async function createLabel(input: LabelCreateInput): Promise<LabelDto> { | ||
| 118 | labelCode: String(input.labelCode ?? "").trim() || null, | 144 | labelCode: String(input.labelCode ?? "").trim() || null, |
| 119 | labelName: input.labelName, | 145 | labelName: input.labelName, |
| 120 | templateCode: input.templateCode, | 146 | templateCode: input.templateCode, |
| 121 | - locationId: input.locationId, | 147 | + ...locationBodyFromInput(input), |
| 122 | labelCategoryId: input.labelCategoryId, | 148 | labelCategoryId: input.labelCategoryId, |
| 123 | labelTypeId: input.labelTypeId, | 149 | labelTypeId: input.labelTypeId, |
| 124 | productIds: input.productIds, | 150 | productIds: input.productIds, |
| @@ -137,7 +163,7 @@ export async function updateLabel(labelCode: string, input: LabelUpdateInput): P | @@ -137,7 +163,7 @@ export async function updateLabel(labelCode: string, input: LabelUpdateInput): P | ||
| 137 | body: { | 163 | body: { |
| 138 | labelName: input.labelName, | 164 | labelName: input.labelName, |
| 139 | templateCode: input.templateCode, | 165 | templateCode: input.templateCode, |
| 140 | - locationId: input.locationId, | 166 | + ...locationBodyFromInput(input), |
| 141 | labelCategoryId: input.labelCategoryId, | 167 | labelCategoryId: input.labelCategoryId, |
| 142 | labelTypeId: input.labelTypeId, | 168 | labelTypeId: input.labelTypeId, |
| 143 | productIds: input.productIds, | 169 | productIds: input.productIds, |
美国版/Food Labeling Management Platform/src/types/label.ts
| @@ -4,6 +4,8 @@ export type LabelDto = { | @@ -4,6 +4,8 @@ export type LabelDto = { | ||
| 4 | labelName?: string | null; | 4 | labelName?: string | null; |
| 5 | templateCode?: string | null; | 5 | templateCode?: string | null; |
| 6 | locationId?: string | null; | 6 | locationId?: string | null; |
| 7 | + /** 详情/编辑:适用门店 Id 列表(与后端 LocationIds 对齐) */ | ||
| 8 | + locationIds?: string[] | null; | ||
| 7 | labelCategoryId?: string | null; | 9 | labelCategoryId?: string | null; |
| 8 | labelTypeId?: string | null; | 10 | labelTypeId?: string | null; |
| 9 | productIds?: string[] | null; | 11 | productIds?: string[] | null; |
| @@ -61,7 +63,8 @@ export type LabelCreateInput = { | @@ -61,7 +63,8 @@ export type LabelCreateInput = { | ||
| 61 | labelCode?: string | null; | 63 | labelCode?: string | null; |
| 62 | labelName: string; | 64 | labelName: string; |
| 63 | templateCode: string; | 65 | templateCode: string; |
| 64 | - locationId: string; | 66 | + /** 适用门店(至少 1 个;Select All 时为当前 Region 下全部门店 Id) */ |
| 67 | + locationIds: string[]; | ||
| 65 | labelCategoryId: string; | 68 | labelCategoryId: string; |
| 66 | labelTypeId: string; | 69 | labelTypeId: string; |
| 67 | productIds: string[]; // 至少 1 个 | 70 | productIds: string[]; // 至少 1 个 |
| @@ -75,7 +78,8 @@ export type LabelCreateInput = { | @@ -75,7 +78,8 @@ export type LabelCreateInput = { | ||
| 75 | export type LabelUpdateInput = { | 78 | export type LabelUpdateInput = { |
| 76 | labelName: string; | 79 | labelName: string; |
| 77 | templateCode: string; | 80 | templateCode: string; |
| 78 | - locationId: string; | 81 | + /** 适用门店(至少 1 个) */ |
| 82 | + locationIds: string[]; | ||
| 79 | labelCategoryId: string; | 83 | labelCategoryId: string; |
| 80 | labelTypeId: string; | 84 | labelTypeId: string; |
| 81 | productIds: string[]; // 至少 1 个 | 85 | productIds: string[]; // 至少 1 个 |