Commit 143afd59f4391431617fa291d152a6b6b04475c4

Authored by 杨鑫
1 parent 13b6d2e3

打印,标签

Showing 57 changed files with 5763 additions and 1086 deletions
标签模块接口对接说明(8).md 0 → 100644
  1 +## 概述
  2 +
  3 +美国版后端采用 ABP 动态接口(ConventionalControllers),宿主统一前缀为 `api/app`。
  4 +Swagger 地址:
  5 +
  6 +- `http://localhost:19001/swagger`
  7 +
  8 +说明:
  9 +- 接口最终 URL 以 Swagger 展示为准(可在 Swagger 里搜索 `LabelCategory / LabelType / LabelMultipleOption / LabelTemplate / Label / UsAppLabeling`)。
  10 +- 本模块后端接口以各 AppService 的方法签名自动暴露。
  11 +- 返回分页统一包含 `PageIndex / PageSize / TotalCount / TotalPages / Items`。
  12 +
  13 +---
  14 +
  15 +## Swagger 中如何找到
  16 +
  17 +1. 启动后端宿主(`Yi.Abp.Web`),端口 `19001`。
  18 +2. 打开 `http://localhost:19001/swagger`。
  19 +3. 在接口分组里搜索以下关键词之一:
  20 + - `label-category`
  21 + - `label-type`
  22 + - `label-multiple-option`
  23 + - `label-template`
  24 + - `label`
  25 + - `us-app-labeling`(App 端 Labeling 四级树)
  26 +
  27 +---
  28 +
  29 +## 接口 1:Label Categories(标签分类)
  30 +
  31 +### 1.1 分页列表
  32 +
  33 +方法:`GET /api/app/label-category`
  34 +
  35 +入参(`LabelCategoryGetListInputVo`,查询参数):
  36 +
  37 +- `skipCount`(int)
  38 +- `maxResultCount`(int)
  39 +- `sorting`(string,可选)
  40 +- `keyword`(string,可选)
  41 +- `state`(boolean,可选)
  42 +
  43 +示例(查询参数):
  44 +
  45 +```json
  46 +{
  47 + "skipCount": 0,
  48 + "maxResultCount": 10,
  49 + "keyword": "Prep",
  50 + "state": true
  51 +}
  52 +```
  53 +
  54 +### 1.2 详情
  55 +
  56 +方法:`GET /api/app/label-category/{id}`
  57 +
  58 +入参:
  59 +
  60 +- `id`:分类 Id(字符串)
  61 +
  62 +### 1.3 新增
  63 +
  64 +方法:`POST /api/app/label-category`
  65 +
  66 +入参(Body:`LabelCategoryCreateInputVo`):
  67 +
  68 +```json
  69 +{
  70 + "categoryCode": "CAT_PREP",
  71 + "categoryName": "Prep",
  72 + "categoryPhotoUrl": "https://cdn.example.com/cat-prep.png",
  73 + "state": true,
  74 + "orderNum": 1
  75 +}
  76 +```
  77 +
  78 +### 1.4 编辑
  79 +
  80 +方法:`PUT /api/app/label-category/{id}`
  81 +
  82 +入参(Body:`LabelCategoryUpdateInputVo`,字段同创建):
  83 +
  84 +```json
  85 +{
  86 + "categoryCode": "CAT_PREP",
  87 + "categoryName": "Prep",
  88 + "categoryPhotoUrl": null,
  89 + "state": true,
  90 + "orderNum": 2
  91 +}
  92 +```
  93 +
  94 +### 1.5 删除(逻辑删除)
  95 +
  96 +方法:`DELETE /api/app/label-category/{id}`
  97 +
  98 +入参:
  99 +
  100 +- `id`:分类 Id(字符串)
  101 +
  102 +删除校验:
  103 +- 若该分类已被 `fl_label` 引用,则抛出友好错误,禁止删除。
  104 +
  105 +---
  106 +
  107 +## 接口 2:Label Types(标签类型)
  108 +
  109 +### 2.1 分页列表
  110 +
  111 +方法:`GET /api/app/label-type`
  112 +
  113 +入参(`LabelTypeGetListInputVo`,查询参数):
  114 +
  115 +```json
  116 +{
  117 + "skipCount": 0,
  118 + "maxResultCount": 10,
  119 + "keyword": "Defrost",
  120 + "state": true
  121 +}
  122 +```
  123 +
  124 +### 2.2 详情
  125 +
  126 +方法:`GET /api/app/label-type/{id}`
  127 +
  128 +入参:
  129 +
  130 +- `id`:类型 Id(字符串)
  131 +
  132 +### 2.3 新增
  133 +
  134 +方法:`POST /api/app/label-type`
  135 +
  136 +入参(Body:`LabelTypeCreateInputVo`):
  137 +
  138 +```json
  139 +{
  140 + "typeCode": "TYPE_DEFROST",
  141 + "typeName": "Defrost",
  142 + "state": true,
  143 + "orderNum": 1
  144 +}
  145 +```
  146 +
  147 +### 2.4 编辑
  148 +
  149 +方法:`PUT /api/app/label-type/{id}`
  150 +
  151 +入参(Body:`LabelTypeUpdateInputVo`,字段同创建):
  152 +
  153 +```json
  154 +{
  155 + "typeCode": "TYPE_DEFROST",
  156 + "typeName": "Defrost",
  157 + "state": true,
  158 + "orderNum": 2
  159 +}
  160 +```
  161 +
  162 +### 2.5 删除(逻辑删除)
  163 +
  164 +方法:`DELETE /api/app/label-type/{id}`
  165 +
  166 +删除校验:
  167 +- 若该类型已被 `fl_label` 引用,则禁止删除。
  168 +
  169 +---
  170 +
  171 +## 接口 3:Multiple Options(多选项字典)
  172 +
  173 +### 3.1 分页列表
  174 +
  175 +方法:`GET /api/app/label-multiple-option`
  176 +
  177 +入参(`LabelMultipleOptionGetListInputVo`,查询参数):
  178 +
  179 +```json
  180 +{
  181 + "skipCount": 0,
  182 + "maxResultCount": 10,
  183 + "keyword": "Allergens",
  184 + "state": true
  185 +}
  186 +```
  187 +
  188 +### 3.2 详情
  189 +
  190 +方法:`GET /api/app/label-multiple-option/{id}`
  191 +
  192 +入参:
  193 +
  194 +- `id`:多选项 Id(字符串)
  195 +
  196 +### 3.3 新增
  197 +
  198 +方法:`POST /api/app/label-multiple-option`
  199 +
  200 +入参(Body:`LabelMultipleOptionCreateInputVo`):
  201 +
  202 +```json
  203 +{
  204 + "optionCode": "OPT_ALLERGENS",
  205 + "optionName": "Allergens",
  206 + "optionValuesJson": ["Peanuts", "Dairy", "Gluten", "Soy"],
  207 + "state": true,
  208 + "orderNum": 1
  209 +}
  210 +```
  211 +
  212 +### 3.4 编辑
  213 +
  214 +方法:`PUT /api/app/label-multiple-option/{id}`
  215 +
  216 +入参(Body:`LabelMultipleOptionUpdateInputVo`,字段同创建):
  217 +
  218 +```json
  219 +{
  220 + "optionCode": "OPT_ALLERGENS",
  221 + "optionName": "Allergens",
  222 + "optionValuesJson": ["Peanuts", "Dairy"],
  223 + "state": true,
  224 + "orderNum": 2
  225 +}
  226 +```
  227 +
  228 +### 3.5 删除(逻辑删除)
  229 +
  230 +方法:`DELETE /api/app/label-multiple-option/{id}`
  231 +
  232 +---
  233 +
  234 +## 接口 4:Label Templates(标签模板)
  235 +
  236 +说明:
  237 +- 模板标识入参 `id` 使用 `fl_label_template.TemplateCode`。
  238 +- 创建/编辑的 Body 字段名对齐你前端 editor JSON(`id/name/appliedLocation/elements/config`)。
  239 +- 模板组件 `elements[]` 的 `elementName` 为必填(前端传值,后端校验为空会报错)。
  240 +- `templateProductDefaults[]` 用于模板内“产品 + 标签类型”绑定默认值(进入模板详情页后的绑定列表)。
  241 +- **新增模板时不处理默认值**;默认值仅在后续“产品关联/编辑模板”阶段维护。
  242 +
  243 +### 4.1 分页列表
  244 +
  245 +方法:`GET /api/app/label-template`
  246 +
  247 +入参(`LabelTemplateGetListInputVo`,查询参数):
  248 +
  249 +```json
  250 +{
  251 + "skipCount": 0,
  252 + "maxResultCount": 10,
  253 + "keyword": "测试模板",
  254 + "locationId": "11111111-1111-1111-1111-111111111111",
  255 + "labelType": "PRICE",
  256 + "state": true
  257 +}
  258 +```
  259 +
  260 +### 4.2 详情
  261 +
  262 +方法:`GET /api/app/label-template/{id}`
  263 +
  264 +入参:
  265 +
  266 +- `id`:模板编码 `TemplateCode`(字符串)
  267 +
  268 +### 4.3 新增模板
  269 +
  270 +方法:`POST /api/app/label-template`
  271 +
  272 +入参(Body:`LabelTemplateCreateInputVo`):
  273 +
  274 +```json
  275 +{
  276 + "id": "TPL_TEST_001",
  277 + "name": "测试模板-价格签(4x6)",
  278 + "labelType": "PRICE",
  279 + "unit": "inch",
  280 + "width": 4,
  281 + "height": 6,
  282 + "appliedLocation": "ALL",
  283 + "showRuler": true,
  284 + "showGrid": true,
  285 + "state": true,
  286 + "elements": [
  287 + {
  288 + "id": "el-fixed-title",
  289 + "elementName": "标题文本",
  290 + "type": "TEXT_STATIC",
  291 + "x": 32,
  292 + "y": 24,
  293 + "width": 160,
  294 + "height": 24,
  295 + "rotation": "horizontal",
  296 + "border": "none",
  297 + "zIndex": 1,
  298 + "orderNum": 1,
  299 + "valueSourceType": "FIXED",
  300 + "isRequiredInput": false,
  301 + "config": {
  302 + "text": "商品名",
  303 + "fontFamily": "Arial",
  304 + "fontSize": 14,
  305 + "fontWeight": "bold",
  306 + "textAlign": "left"
  307 + }
  308 + }
  309 + ],
  310 + "appliedLocationIds": []
  311 +}
  312 +```
  313 +
  314 +说明:
  315 +- 当 `appliedLocation=SPECIFIED` 时,`appliedLocationIds` 必须至少选择一个门店。
  316 +- `elements[].elementName` 必填;为空或空白将返回友好错误:`组件名字不能为空`。
  317 +- 新增模板时即使传了 `templateProductDefaults`,后端也不会写入默认值数据。
  318 +
  319 +### 4.4 编辑模板
  320 +
  321 +方法:`PUT /api/app/label-template/{id}`
  322 +
  323 +入参:
  324 +- Path:`id` 是当前模板编码(TemplateCode)
  325 +- Body:字段同新增(`id/name/elements/...`)
  326 +
  327 +示例(编辑:同样字段,appliedLocation 切到 SPECIFIED):
  328 +
  329 +```json
  330 +{
  331 + "id": "TPL_TEST_001",
  332 + "name": "测试模板-价格签(4x6) v2",
  333 + "labelType": "PRICE",
  334 + "unit": "inch",
  335 + "width": 4,
  336 + "height": 6,
  337 + "appliedLocation": "SPECIFIED",
  338 + "showRuler": true,
  339 + "showGrid": true,
  340 + "state": true,
  341 + "elements": [
  342 + {
  343 + "id": "el-price",
  344 + "elementName": "价格文本",
  345 + "type": "TEXT_PRICE",
  346 + "x": 40,
  347 + "y": 120,
  348 + "width": 140,
  349 + "height": 28,
  350 + "rotation": "horizontal",
  351 + "border": "none",
  352 + "zIndex": 2,
  353 + "orderNum": 2,
  354 + "valueSourceType": "PRINT_INPUT",
  355 + "inputKey": "price",
  356 + "isRequiredInput": true,
  357 + "config": {
  358 + "text": "",
  359 + "fontFamily": "Arial",
  360 + "fontSize": 18,
  361 + "fontWeight": "bold",
  362 + "textAlign": "left"
  363 + }
  364 + }
  365 + ],
  366 + "appliedLocationIds": ["11111111-1111-1111-1111-111111111111"],
  367 + "templateProductDefaults": [
  368 + {
  369 + "productId": "3a20-xxxx",
  370 + "labelTypeId": "3a20-yyyy",
  371 + "defaultValues": {
  372 + "el-fixed-title": "Chicken",
  373 + "el-price": "2.00",
  374 + "el-desc": "23"
  375 + },
  376 + "orderNum": 1
  377 + }
  378 + ]
  379 +}
  380 +```
  381 +
  382 +版本:
  383 +- `VersionNo` 会在编辑时自动 `+1`。
  384 +- `elements` 会按传入内容全量重建。
  385 +- 查询/预览返回的 `elements[]` 同样会带 `elementName` 字段。
  386 +- `templateProductDefaults` 在编辑接口中**仅当显式传入时**才会重建(同一模板先删后插)。
  387 +- 若编辑时不传 `templateProductDefaults`,后端会保留数据库中原有默认值,不做覆盖。
  388 +
  389 +`templateProductDefaults` 结构说明:
  390 +- 每一行需传 `productId` 与 `labelTypeId`。
  391 +- `defaultValues` 建议使用 `element.id => 默认文本` 结构;页面展示时可结合模板 `elements[].config.text` 作为列头与初始值。
  392 +
  393 +### 4.6 模板与产品默认值关联表(新增)
  394 +
  395 +用于存储“模板-产品-标签类型”的默认值,推荐执行以下建表 SQL:
  396 +
  397 +```sql
  398 +CREATE TABLE `fl_label_template_product_default` (
  399 + `Id` varchar(36) NOT NULL COMMENT '主键',
  400 + `TemplateId` varchar(36) NOT NULL COMMENT '模板Id(关联 fl_label_template.Id)',
  401 + `ProductId` varchar(36) NOT NULL COMMENT '产品Id(关联 fl_product.Id)',
  402 + `LabelTypeId` varchar(36) NOT NULL COMMENT '标签类型Id(关联 fl_label_type.Id)',
  403 + `DefaultValuesJson` text NULL COMMENT '默认值JSON(如 elementId=>默认文本)',
  404 + `OrderNum` int NOT NULL DEFAULT 1 COMMENT '排序',
  405 + PRIMARY KEY (`Id`),
  406 + KEY `idx_fl_ltpd_template` (`TemplateId`),
  407 + KEY `idx_fl_ltpd_product` (`ProductId`),
  408 + KEY `idx_fl_ltpd_label_type` (`LabelTypeId`),
  409 + UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductId`, `LabelTypeId`)
  410 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签模板-产品默认值关联表';
  411 +```
  412 +
  413 +若表已存在,可执行以下 SQL 增加唯一约束:
  414 +
  415 +```sql
  416 +ALTER TABLE `fl_label_template_product_default`
  417 +ADD UNIQUE KEY `uk_fl_ltpd_template_product_label_type` (`TemplateId`, `ProductId`, `LabelTypeId`);
  418 +```
  419 +
  420 +### 4.5 删除(逻辑删除)
  421 +
  422 +方法:`DELETE /api/app/label-template/{id}`
  423 +
  424 +入参:
  425 +- `id`:模板编码 `TemplateCode`
  426 +
  427 +删除校验:
  428 +- 若该模板已被 `fl_label` 引用,则禁止删除。
  429 +
  430 +---
  431 +
  432 +## 接口 5:Labels(按产品展示多个标签)
  433 +
  434 +说明:
  435 +- 列表接口以“标签”为维度分页展示(同一个标签会绑定多个产品)。
  436 +- 列表支持按 `ProductId` 过滤:仅返回“绑定了该产品”的标签。
  437 +- 标签详情/编辑/删除的 `id` 使用 `fl_label.LabelCode`。
  438 +
  439 +### 5.1 分页列表(按产品)
  440 +
  441 +方法:`GET /api/app/label`
  442 +
  443 +入参(`LabelGetListInputVo`,查询参数):
  444 +
  445 +```json
  446 +{
  447 + "skipCount": 0,
  448 + "maxResultCount": 10,
  449 + "sorting": "",
  450 + "keyword": "早餐",
  451 + "locationId": "11111111-1111-1111-1111-111111111111",
  452 + "productId": "22222222-2222-2222-2222-222222222222",
  453 + "labelCategoryId": "33333333-3333-3333-3333-333333333333",
  454 + "labelTypeId": "44444444-4444-4444-4444-444444444444",
  455 + "templateCode": "TPL_TEST_001",
  456 + "state": true
  457 +}
  458 +```
  459 +
  460 +列表出参要点(`LabelGetListOutputDto`):
  461 +
  462 +- `products`:同一个标签下绑定的产品名称,用 `,` 分割(例如:`Chicken,Sandwich`)
  463 +- 其他字段与之前一致:`labelName/locationName/category/type/template/state/lastEdited...`
  464 +
  465 +### 5.2 详情
  466 +
  467 +方法:`GET /api/app/label/{id}`
  468 +
  469 +入参:
  470 +- `id`:标签编码 `LabelCode`
  471 +
  472 +返回:
  473 +- `productIds`:该标签绑定的产品Id 列表
  474 +
  475 +### 5.3 新增标签
  476 +
  477 +方法:`POST /api/app/label`
  478 +
  479 +入参(Body:`LabelCreateInputVo`):
  480 +
  481 +```json
  482 +{
  483 + "labelCode": "LBL_TEST_001",
  484 + "labelName": "早餐标签",
  485 + "templateCode": "TPL_TEST_001",
  486 + "locationId": "11111111-1111-1111-1111-111111111111",
  487 + "labelCategoryId": "33333333-3333-3333-3333-333333333333",
  488 + "labelTypeId": "44444444-4444-4444-4444-444444444444",
  489 + "productIds": ["22222222-2222-2222-2222-222222222222"],
  490 + "labelInfoJson": { "note": "测试标签1" },
  491 + "state": true
  492 +}
  493 +```
  494 +
  495 +校验:
  496 +- `productIds` 至少 1 个
  497 +- `templateCode/locationId/labelCategoryId/labelTypeId` 不能为空
  498 +
  499 +### 5.4 编辑标签
  500 +
  501 +方法:`PUT /api/app/label/{id}`
  502 +
  503 +入参:
  504 +- Path:`id` 为当前标签编码 `LabelCode`
  505 +- Body:字段同创建(`LabelUpdateInputVo`)
  506 +
  507 +```json
  508 +{
  509 + "labelName": "早餐标签 v2",
  510 + "templateCode": "TPL_TEST_001",
  511 + "locationId": "11111111-1111-1111-1111-111111111111",
  512 + "labelCategoryId": "33333333-3333-3333-3333-333333333333",
  513 + "labelTypeId": "44444444-4444-4444-4444-444444444444",
  514 + "productIds": ["22222222-2222-2222-2222-222222222222"],
  515 + "labelInfoJson": { "note": "测试标签1 v2" },
  516 + "state": true
  517 +}
  518 +```
  519 +
  520 +关联维护:
  521 +- `fl_label_product` 会按新 `productIds` 重建。
  522 +
  523 +### 5.5 删除标签(逻辑删除)
  524 +
  525 +方法:`DELETE /api/app/label/{id}`
  526 +
  527 +入参:
  528 +- `id`:标签编码 `LabelCode`
  529 +
  530 +删除行为:
  531 +- 逻辑删除 `fl_label`
  532 +- 删除该标签对应的 `fl_label_product` 关联
  533 +
  534 +---
  535 +## 接口 6:Products(产品)
  536 +
  537 +说明:
  538 +- 产品表:`fl_product`
  539 +- 删除为逻辑删除:`IsDeleted = true`
  540 +
  541 +### 6.1 分页列表
  542 +
  543 +方法:`GET /api/app/product`
  544 +
  545 +入参(`ProductGetListInputVo`,查询参数):
  546 +```json
  547 +{
  548 + "skipCount": 0,
  549 + "maxResultCount": 10,
  550 + "sorting": "",
  551 + "keyword": "Chicken",
  552 + "state": true
  553 +}
  554 +```
  555 +
  556 +### 6.2 详情
  557 +
  558 +方法:`GET /api/app/product/{id}`
  559 +
  560 +入参:
  561 +- `id`:产品Id(`fl_product.Id`)
  562 +
  563 +### 6.3 新增产品
  564 +
  565 +方法:`POST /api/app/product`
  566 +
  567 +入参(Body:`ProductCreateInputVo`):
  568 +```json
  569 +{
  570 + "productCode": "PRD_TEST_001",
  571 + "productName": "Chicken",
  572 + "categoryName": "Meat",
  573 + "productImageUrl": "https://example.com/img.png",
  574 + "state": true
  575 +}
  576 +```
  577 +
  578 +校验:
  579 +- `productCode/productName` 不能为空
  580 +- `productCode` 不能与未删除的数据重复
  581 +
  582 +### 6.4 编辑产品
  583 +
  584 +方法:`PUT /api/app/product/{id}`
  585 +
  586 +入参:
  587 +- Path:`id` 为当前产品Id(`fl_product.Id`)
  588 +- Body:字段同新增(`ProductUpdateInputVo`)
  589 +
  590 +### 6.5 删除(逻辑删除)
  591 +
  592 +方法:`DELETE /api/app/product/{id}`
  593 +
  594 +入参:
  595 +- `id`:产品Id
  596 +
  597 +---
  598 +## 接口 7:Product-Location(门店-产品关联)
  599 +
  600 +说明:
  601 +- 关联表:`fl_location_product`
  602 +- 关联按门店进行批量替换:
  603 + - `Create`:在门店下新增未存在的 product 关联
  604 + - `Update`:替换该门店下全部关联(先删后建)
  605 + - `Delete`:删除该门店下全部关联
  606 +
  607 +### 7.1 分页列表
  608 +
  609 +方法:`GET /api/app/product-location`
  610 +
  611 +入参(`ProductLocationGetListInputVo`,查询参数):
  612 +```json
  613 +{
  614 + "skipCount": 0,
  615 + "maxResultCount": 10,
  616 + "sorting": "",
  617 + "locationId": "11111111-1111-1111-1111-111111111111",
  618 + "productId": "22222222-2222-2222-2222-222222222222"
  619 +}
  620 +```
  621 +
  622 +### 7.2 获取门店下全部产品
  623 +
  624 +方法:`GET /api/app/product-location/{id}`
  625 +
  626 +入参:
  627 +- `id`:门店Id(`location.Id`,string 表示)
  628 +
  629 +返回:
  630 +- 门店Id + 该门店关联的产品列表
  631 +
  632 +### 7.3 新增/建立门店关联
  633 +
  634 +方法:`POST /api/app/product-location`
  635 +
  636 +入参(Body:`ProductLocationCreateInputVo`):
  637 +```json
  638 +{
  639 + "locationId": "11111111-1111-1111-1111-111111111111",
  640 + "productIds": ["22222222-2222-2222-2222-222222222222"]
  641 +}
  642 +```
  643 +
  644 +校验:
  645 +- `locationId` 对应门店必须存在
  646 +- `productIds` 必须都存在于 `fl_product` 且未删除
  647 +
  648 +### 7.4 编辑/替换门店关联
  649 +
  650 +方法:`PUT /api/app/product-location/{id}`
  651 +
  652 +入参:
  653 +- Path:`id` 为门店Id
  654 +- Body:`ProductLocationUpdateInputVo`
  655 +```json
  656 +{
  657 + "productIds": ["22222222-2222-2222-2222-222222222222"]
  658 +}
  659 +```
  660 +
  661 +### 7.5 删除门店关联(按门店删除全部)
  662 +
  663 +方法:`DELETE /api/app/product-location/{id}`
  664 +
  665 +入参:
  666 +- `id`:门店Id
  667 +
  668 +---
  669 +
  670 +## 接口 8:App Labeling 四级列表(门店打标页)
  671 +
  672 +**场景**:美国版 UniApp「Labeling」页:左侧 **标签分类(Label Category)** → 主区域按 **产品分类(Product Category)** 折叠分组 → **产品(Product)** 卡片 → 点选后底部弹层展示 **标签种类(Label Type)**。
  673 +
  674 +**实现**:`UsAppLabelingAppService.GetLabelingTreeAsync`,约定式 API 控制器名 **`us-app-labeling`**(与 `UsAppAuth` → `us-app-auth` 同规则)。
  675 +
  676 +### 8.1 获取四级嵌套树
  677 +
  678 +#### HTTP
  679 +
  680 +- **方法**:`GET`
  681 +- **路径**:`/api/app/us-app-labeling/labeling-tree`(若与 Swagger 不一致,**以 Swagger 为准**)
  682 +- **鉴权**:需要登录(`Authorization: Bearer ...`);可使用 App 登录或 Web 账号 Token,需能通过 `[Authorize]`。当前用户可选门店列表见 **`/api/app/us-app-auth/my-locations`**(说明见 `美国版App登录接口说明.md`)。
  683 +
  684 +#### 入参(Query:`UsAppLabelingTreeInputVo`)
  685 +
  686 +| 参数名 | 类型 | 必填 | 说明 |
  687 +|--------|------|------|------|
  688 +| `locationId` | string | 是 | 当前门店 Id(`location.Id`,与 `fl_location_product.LocationId`、`fl_label.LocationId` 一致) |
  689 +| `keyword` | string | 否 | 模糊过滤:标签名、产品名、产品分类、**标签分类**名、标签类型名、`labelCode` 等(实现见服务内 `WhereIF`) |
  690 +| `labelCategoryId` | string | 否 | 侧边栏只展示某一 **标签分类** 时传入;不传则返回当前门店下出现的全部标签分类节点 |
  691 +
  692 +#### 数据范围与表关联(便于联调对照)
  693 +
  694 +- **门店内商品**:`fl_location_product`(先有 `locationId` 下的 `productId` 集合)。
  695 +- **参与连接的表**:`fl_label_product`(标签-产品)、`fl_label`(`LocationId` 须为当前门店且未删除、启用)、`fl_product`、`fl_label_category`、`fl_label_type`、`fl_label_template`。
  696 +- **第二级「产品分类」**:来自 `fl_product.CategoryName`,trim 后为空则归并为显示名 **`无`**。
  697 +- **第四级去重**:同一产品在同一标签分类、同一门店下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。
  698 +
  699 +#### 出参(`List<UsAppLabelCategoryTreeNodeDto>`)
  700 +
  701 +若宿主对成功结果有统一包装,业务数组一般在 **`data`** 中;下列为 **解包后的数组项** 结构。
  702 +
  703 +**L1 `UsAppLabelCategoryTreeNodeDto`(标签分类)**
  704 +
  705 +| 字段 | 类型 | 说明 |
  706 +|------|------|------|
  707 +| `id` | string | `fl_label_category.Id` |
  708 +| `categoryName` | string | 分类名称 |
  709 +| `categoryPhotoUrl` | string \| null | 分类图标/图 |
  710 +| `orderNum` | number | 排序 |
  711 +| `productCategories` | array | 第二级列表(见下表) |
  712 +
  713 +**L2 `UsAppProductCategoryNodeDto`(产品分类)**
  714 +
  715 +| 字段 | 类型 | 说明 |
  716 +|------|------|------|
  717 +| `categoryId` | string \| null | 产品分类Id;产品未归类或分类不存在时为空 |
  718 +| `categoryPhotoUrl` | string \| null | 产品分类图片地址;产品未归类或分类不存在时为空 |
  719 +| `name` | string | 产品分类显示名;空源数据为 **`无`** |
  720 +| `itemCount` | number | 该分类下 **产品个数**(去重后的产品数) |
  721 +| `products` | array | 第三级产品列表(见下表) |
  722 +
  723 +**L3 `UsAppLabelingProductNodeDto`(产品)**
  724 +
  725 +| 字段 | 类型 | 说明 |
  726 +|------|------|------|
  727 +| `productId` | string | `fl_product.Id` |
  728 +| `productName` | string | 产品名称 |
  729 +| `productCode` | string | 产品编码 |
  730 +| `productImageUrl` | string \| null | 主图 |
  731 +| `subtitle` | string | 卡片副标题:**有 `productCode` 则显示编码,否则「无」**(与原型「Basic」等独立文案不同,需另行扩展字段时再对齐) |
  732 +| `labelTypeCount` | number | 第四级条数,可用于角标「N Types」 |
  733 +| `labelTypes` | array | 第四级(见下表) |
  734 +
  735 +**L4 `UsAppLabelTypeNodeDto`(标签种类 / 可选项)**
  736 +
  737 +| 字段 | 类型 | 说明 |
  738 +|------|------|------|
  739 +| `labelTypeId` | string | `fl_label_type.Id` |
  740 +| `typeName` | string | 类型名称(如 Defrost) |
  741 +| `orderNum` | number | 排序 |
  742 +| `labelCode` | string | 业务标签编码,后续预览、打印流程使用 |
  743 +| `templateCode` | string \| null | 关联模板编码 |
  744 +| `labelSizeText` | string \| null | 尺寸文案;`inch` 常用格式如 `2"x2"` |
  745 +
  746 +#### 错误与边界
  747 +
  748 +- `locationId` 为空:返回友好错误 **「门店Id不能为空」**。
  749 +- 门店下无关联产品:返回 **空数组** `[]`。
  750 +- 有产品但无任何符合条件的标签关联:返回 **空数组** `[]`。
  751 +
  752 +#### 请求示例
  753 +
  754 +```http
  755 +GET /api/app/us-app-labeling/labeling-tree?locationId=11111111-1111-1111-1111-111111111111&labelCategoryId=a2696b9e-2277-11f1-b4c6-00163e0c7c4f&keyword=Chicken HTTP/1.1
  756 +Host: localhost:19001
  757 +Authorization: Bearer eyJhbGciOi...
  758 +```
  759 +
  760 +**curl**(Token 取自登录响应的 `data.token` 整段,已含 `Bearer ` 前缀时直接放入 Header):
  761 +
  762 +```bash
  763 +curl -X GET "http://localhost:19001/api/app/us-app-labeling/labeling-tree?locationId=11111111-1111-1111-1111-111111111111" \
  764 + -H "Authorization: <data.token>"
  765 +```
  766 +
  767 +#### 响应结构示例(解包后)
  768 +
  769 +```json
  770 +[
  771 + {
  772 + "id": "cat-prep-id",
  773 + "categoryName": "Prep",
  774 + "categoryPhotoUrl": "/picture/...",
  775 + "orderNum": 1,
  776 + "productCategories": [
  777 + {
  778 + "categoryId": "pc-meat-id",
  779 + "categoryPhotoUrl": "/picture/product-category/20260325123010_xxx.png",
  780 + "name": "Meat",
  781 + "itemCount": 1,
  782 + "products": [
  783 + {
  784 + "productId": "prod-chicken-id",
  785 + "productName": "Chicken",
  786 + "productCode": "CHK-001",
  787 + "productImageUrl": "/picture/...",
  788 + "subtitle": "CHK-001",
  789 + "labelTypeCount": 3,
  790 + "labelTypes": [
  791 + {
  792 + "labelTypeId": "lt-defrost",
  793 + "typeName": "Defrost",
  794 + "orderNum": 1,
  795 + "labelCode": "LBL_CHICKEN_DEFROST",
  796 + "templateCode": "TPL_2X2",
  797 + "labelSizeText": "2\"x2\""
  798 + }
  799 + ]
  800 + }
  801 + ]
  802 + }
  803 + ]
  804 + }
  805 +]
  806 +```
  807 +
  808 +> 前端 Axios 若项目约定 **GET 使用 `data` 配置对象** 传参,请仍绑定到与上述 Query 同名的字段(`locationId`、`keyword`、`labelCategoryId`),与 URL Query 等价即可。
  809 +
  810 +### 8.2 App 打印预览(elements 渲染结构)
  811 +
  812 +**场景**:用户选择某个 Product + Label Type 进入「Label Preview」页面,需要把模板预览区域渲染出来。
  813 +后端根据 `labelCode` 读取模板(`fl_label_template` + `fl_label_template_element`),并将 AUTO_DB / PRINT_INPUT 的值渲染回每个 element 的 `config`,前端按 `elements` 自行绘制预览。
  814 +
  815 +#### HTTP
  816 +
  817 +- **方法**:`POST`
  818 +- **路径**:`/api/app/us-app-labeling/preview`(若与 Swagger 不一致,**以 Swagger 为准**)
  819 +- **鉴权**:需要登录(`Authorization: Bearer ...`)
  820 +
  821 +#### 入参(Body:`UsAppLabelPreviewInputVo`)
  822 +
  823 +| 参数名(JSON) | 类型 | 必填 | 说明 |
  824 +|---|---|---|---|
  825 +| `locationId` | string | 是 | 门店Id(校验 `fl_label.LocationId` 必须一致) |
  826 +| `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) |
  827 +| `productId` | string | 否 | 预览用产品Id;不传则默认取该标签绑定的第一个产品(用于 AUTO_DB 数据填充) |
  828 +| `baseTime` | string | 否 | 业务基准时间(用于 DATE/TIME 元素计算;不传则用服务器当前时间) |
  829 +| `printInputJson` | object | 否 | 打印输入(用于 PRINT_INPUT 元素),key 建议与模板元素 `inputKey` 对齐 |
  830 +
  831 +#### 出参(`UsAppLabelPreviewDto`)
  832 +
  833 +除顶部信息外,核心是 `template`(供前端画布渲染):
  834 +
  835 +- `template`:`LabelTemplatePreviewDto`
  836 + - `width` / `height` / `unit`:模板物理尺寸
  837 + - `elements[]`:元素数组(对齐前端 editor JSON:`id/elementName/type/x/y/width/height/rotation/border/zIndex/orderNum/config`)
  838 +
  839 +`elements[].config` 内常用字段(示例):
  840 +
  841 +- 文本类(如 `TEXT_PRODUCT` / `TEXT_STATIC` / `TEXT_PRICE`):`config.text`
  842 +- 条码/二维码(`BARCODE` / `QRCODE`):`config.data`
  843 +- 日期/时间(`DATE` / `TIME`):`config.format`(后端已计算并写回)
  844 +
  845 +#### 数据来源说明
  846 +
  847 +- 模板头:`fl_label_template`
  848 +- 模板元素:`fl_label_template_element`(按 `OrderNum` + `ZIndex` 排序)
  849 +- 标签归属:`fl_label`(校验 `labelCode` 存在且 `LocationId == locationId`)
  850 +
  851 +#### 错误与边界
  852 +
  853 +- `locationId` 为空:友好错误 **「门店Id不能为空」**。
  854 +- `labelCode` 为空:友好错误 **「labelCode不能为空」**。
  855 +- 标签不存在:友好错误 **「标签不存在」**。
  856 +- 模板不存在:友好错误 **「模板不存在」**。
  857 +- 标签不属于当前门店:友好错误 **「该标签不属于当前门店」**。
  858 +- 标签未绑定产品且未传 `productId`:友好错误 **「该标签未绑定产品,无法预览」**。
  859 +
  860 +#### 请求示例
  861 +
  862 +```json
  863 +{
  864 + "locationId": "11111111-1111-1111-1111-111111111111",
  865 + "labelCode": "LBL_CHICKEN_DEFROST",
  866 + "productId": "22222222-2222-2222-2222-222222222222",
  867 + "baseTime": "2026-03-26T10:30:00",
  868 + "printInputJson": {
  869 + "price": "12.99"
  870 + }
  871 +}
  872 +```
  873 +
  874 +**curl:**
  875 +
  876 +```bash
  877 +curl -X POST "http://localhost:19001/api/app/us-app-labeling/preview" \
  878 + -H "Authorization: <data.token>" \
  879 + -H "Content-Type: application/json" \
  880 + -d '{"locationId":"11111111-1111-1111-1111-111111111111","labelCode":"LBL_CHICKEN_DEFROST","productId":"22222222-2222-2222-2222-222222222222","baseTime":"2026-03-26T10:30:00","printInputJson":{"price":"12.99"}}'
  881 +```
  882 +
  883 +---
  884 +
  885 +## 接口 9:App 打印(落库打印任务与明细)
  886 +
  887 +**场景**:移动端预览确认后点击 **Print**。后端负责把“本次打印”写入数据库,方便追溯/统计/重打。
  888 +
  889 +### 9.1 创建打印任务并写入明细
  890 +
  891 +#### HTTP
  892 +
  893 +- **方法**:`POST`
  894 +- **路径**:`/api/app/us-app-labeling/print`(若与 Swagger 不一致,**以 Swagger 为准**)
  895 +- **鉴权**:需要登录(`Authorization: Bearer ...`)
  896 +
  897 +#### 入参(Body:`UsAppLabelPrintInputVo`)
  898 +
  899 +| 参数名(JSON) | 类型 | 必填 | 说明 |
  900 +|---|---|---|---|
  901 +| `locationId` | string | 是 | 门店Id(校验 `fl_label.LocationId` 必须一致) |
  902 +| `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) |
  903 +| `productId` | string | 否 | 打印用产品Id;不传则默认取该标签绑定的第一个产品(用于模板解析) |
  904 +| `printQuantity` | number | 否 | 打印份数;`<=0` 按 1 处理 |
  905 +| `baseTime` | string | 否 | 业务基准时间(用于 DATE/TIME 元素计算) |
  906 +| `printInputJson` | object | 否 | 打印输入(用于模板 PRINT_INPUT 元素),key 建议与模板元素 `inputKey` 对齐 |
  907 +| `printerId` | string | 否 | 打印机Id(可选,用于追踪) |
  908 +| `printerMac` | string | 否 | 打印机蓝牙 MAC(可选) |
  909 +| `printerAddress` | string | 否 | 打印机地址(可选) |
  910 +
  911 +#### 数据落库说明
  912 +
  913 +- **任务表**:`fl_label_print_task`
  914 + - 插入 1 条任务记录(`locationId / labelCode / productId / labelTypeId / templateCode / printQuantity / baseTime / printer...` 等)。
  915 +- **明细表**:`fl_label_print_data`
  916 + - 按 `printQuantity` 插入 N 条明细记录(`copyIndex = 1..N`)。
  917 + - `printInputJson`:保存本次打印的原始输入(JSON 字符串)。
  918 + - `renderDataJson`:保存本次解析后的模板预览结构(`LabelTemplatePreviewDto`,包含 resolved 后的 `elements[].config`),供追溯/重打使用。
  919 +
  920 +> 模板解析的数据源来自 `fl_label_template` + `fl_label_template_element`,与预览接口一致。
  921 +
  922 +#### 出参(`UsAppLabelPrintOutputDto`)
  923 +
  924 +| 字段 | 类型 | 说明 |
  925 +|---|---|---|
  926 +| `taskId` | string | 打印任务Id(用于后续查询/重打/统计) |
  927 +| `printQuantity` | number | 实际写入的份数 |
  928 +
  929 +#### 错误与边界
  930 +
  931 +- `locationId` 为空:友好错误 **「门店Id不能为空」**。
  932 +- `labelCode` 为空:友好错误 **「labelCode不能为空」**。
  933 +- 标签不存在/不可用:友好错误 **「标签不存在或不可用」**。
  934 +- 标签不属于当前门店:友好错误 **「该标签不属于当前门店」**。
  935 +- 标签未绑定产品且未传 `productId`:友好错误 **「该标签未绑定产品,无法预览」**(模板解析阶段抛出)。
  936 +
  937 +#### 请求示例
  938 +
  939 +```json
  940 +{
  941 + "locationId": "11111111-1111-1111-1111-111111111111",
  942 + "labelCode": "LBL_CHICKEN_DEFROST",
  943 + "productId": "22222222-2222-2222-2222-222222222222",
  944 + "printQuantity": 2,
  945 + "baseTime": "2026-03-26T10:30:00",
  946 + "printInputJson": {
  947 + "price": "12.99"
  948 + },
  949 + "printerMac": "AA:BB:CC:DD:EE:FF"
  950 +}
  951 +```
  952 +
  953 +**curl:**
  954 +
  955 +```bash
  956 +curl -X POST "http://localhost:19001/api/app/us-app-labeling/print" \
  957 + -H "Authorization: <data.token>" \
  958 + -H "Content-Type: application/json" \
  959 + -d '{"locationId":"11111111-1111-1111-1111-111111111111","labelCode":"LBL_CHICKEN_DEFROST","productId":"22222222-2222-2222-2222-222222222222","printQuantity":2,"baseTime":"2026-03-26T10:30:00","printInputJson":{"price":"12.99"}}'
  960 +```
  961 +
... ...
美国版/Food Labeling Management App UniApp/src/components/NoPrinterModal.vue 0 → 100644
  1 +<template>
  2 + <view v-if="modelValue" class="npm-mask" @click="onMask">
  3 + <view class="npm-card" @click.stop>
  4 + <text class="npm-title">No Printer Connected</text>
  5 + <text class="npm-msg">Please connect a Bluetooth or built-in printer first.</text>
  6 + <view class="npm-divider-h" />
  7 + <view class="npm-actions">
  8 + <view class="npm-btn npm-btn-cancel" @click="emitCancel">
  9 + <text class="npm-btn-text">Cancel</text>
  10 + </view>
  11 + <view class="npm-divider-v" />
  12 + <view class="npm-btn npm-btn-connect" @click="emitConnect">
  13 + <text class="npm-btn-text primary">Connect</text>
  14 + </view>
  15 + </view>
  16 + </view>
  17 + </view>
  18 +</template>
  19 +
  20 +<script setup lang="ts">
  21 +const props = defineProps<{
  22 + modelValue: boolean
  23 +}>()
  24 +
  25 +const emit = defineEmits<{
  26 + (e: 'update:modelValue', v: boolean): void
  27 + (e: 'connect'): void
  28 + (e: 'cancel'): void
  29 +}>()
  30 +
  31 +function close() {
  32 + emit('update:modelValue', false)
  33 +}
  34 +
  35 +function onMask() {
  36 + emit('cancel')
  37 + close()
  38 +}
  39 +
  40 +function emitCancel() {
  41 + emit('cancel')
  42 + close()
  43 +}
  44 +
  45 +function emitConnect() {
  46 + emit('connect')
  47 + close()
  48 +}
  49 +</script>
  50 +
  51 +<style scoped>
  52 +.npm-mask {
  53 + position: fixed;
  54 + left: 0;
  55 + right: 0;
  56 + top: 0;
  57 + bottom: 0;
  58 + background: rgba(0, 0, 0, 0.45);
  59 + z-index: 2000;
  60 + display: flex;
  61 + align-items: center;
  62 + justify-content: center;
  63 + padding: 48rpx;
  64 +}
  65 +
  66 +.npm-card {
  67 + width: 100%;
  68 + max-width: 560rpx;
  69 + background: #fff;
  70 + border-radius: 20rpx;
  71 + overflow: hidden;
  72 + box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.12);
  73 +}
  74 +
  75 +.npm-title {
  76 + display: block;
  77 + text-align: center;
  78 + font-size: 34rpx;
  79 + font-weight: 600;
  80 + color: #111827;
  81 + padding: 40rpx 32rpx 16rpx;
  82 +}
  83 +
  84 +.npm-msg {
  85 + display: block;
  86 + text-align: center;
  87 + font-size: 28rpx;
  88 + color: #6b7280;
  89 + line-height: 1.45;
  90 + padding: 0 40rpx 32rpx;
  91 +}
  92 +
  93 +.npm-divider-h {
  94 + height: 1rpx;
  95 + background: #e5e7eb;
  96 +}
  97 +
  98 +.npm-actions {
  99 + display: flex;
  100 + flex-direction: row;
  101 + align-items: stretch;
  102 + min-height: 100rpx;
  103 +}
  104 +
  105 +.npm-btn {
  106 + flex: 1;
  107 + display: flex;
  108 + align-items: center;
  109 + justify-content: center;
  110 + padding: 24rpx 16rpx;
  111 +}
  112 +
  113 +.npm-divider-v {
  114 + width: 1rpx;
  115 + background: #e5e7eb;
  116 +}
  117 +
  118 +.npm-btn-text {
  119 + font-size: 32rpx;
  120 + font-weight: 500;
  121 + color: #111827;
  122 +}
  123 +
  124 +.npm-btn-text.primary {
  125 + color: #2563eb;
  126 + font-weight: 600;
  127 +}
  128 +</style>
... ...
美国版/Food Labeling Management App UniApp/src/components/SideMenu.vue
... ... @@ -111,6 +111,15 @@ const items = [
111 111 { key: 'home', path: '/pages/index/index', icon: 'home', labelKey: 'Home' },
112 112 { key: 'Labeling', path: '/pages/labels/labels', icon: 'tag', labelKey: 'Labeling' },
113 113 {
  114 + key: 'categories',
  115 + icon: 'squares',
  116 + labelKey: 'categories.menuGroup',
  117 + children: [
  118 + { path: '/pages/categories/product-categories', labelKey: 'categories.productCategories' },
  119 + { path: '/pages/categories/label-categories', labelKey: 'categories.labelCategories' },
  120 + ],
  121 + },
  122 + {
114 123 key: 'report',
115 124 icon: 'fileText',
116 125 labelKey: 'more.report',
... ... @@ -138,6 +147,12 @@ watch(
138 147 if (currentPath.value.startsWith('/pages/more/print-log') || currentPath.value.startsWith('/pages/more/label-report')) {
139 148 expandedKey.value = 'report'
140 149 }
  150 + if (
  151 + currentPath.value.startsWith('/pages/categories/product-categories') ||
  152 + currentPath.value.startsWith('/pages/categories/label-categories')
  153 + ) {
  154 + expandedKey.value = 'categories'
  155 + }
141 156 nextTick(() => {
142 157 animClass.value = 'opening'
143 158 })
... ...
美国版/Food Labeling Management App UniApp/src/locales/en.ts
1 1 export default {
2 2 Home: 'Home',
3 3 Labeling: 'Labeling',
  4 + categories: {
  5 + menuGroup: 'Categories',
  6 + productCategories: 'Product categories',
  7 + labelCategories: 'Label categories',
  8 + productTitle: 'Product categories',
  9 + labelTitle: 'Label categories',
  10 + searchPlaceholder: 'Search by name or code…',
  11 + loading: 'Loading…',
  12 + empty: 'No categories found',
  13 + loadFailed: 'Failed to load',
  14 + active: 'Active',
  15 + inactive: 'Inactive',
  16 + pullMore: 'Scroll for more',
  17 + },
4 18 common: { back: 'Back', confirm: 'Confirm', cancel: 'Cancel', online: 'Online' },
5 19 login: {
6 20 appName: 'Food Label System',
... ...
美国版/Food Labeling Management App UniApp/src/locales/zh.ts
1 1 export default {
2 2 Home: '首页',
3 3 Labeling: '标签',
  4 + categories: {
  5 + menuGroup: '分类目录',
  6 + productCategories: '产品分类',
  7 + labelCategories: '标签分类',
  8 + productTitle: '产品分类',
  9 + labelTitle: '标签分类',
  10 + searchPlaceholder: '按名称或编码搜索…',
  11 + loading: '加载中…',
  12 + empty: '暂无分类',
  13 + loadFailed: '加载失败',
  14 + active: '启用',
  15 + inactive: '停用',
  16 + pullMore: '上拉加载更多',
  17 + },
4 18 common: { back: '返回', confirm: '确认', cancel: '取消', online: '在线' },
5 19 login: {
6 20 appName: '食品标签系统',
... ...
美国版/Food Labeling Management App UniApp/src/pages.json
... ... @@ -29,6 +29,20 @@
29 29 }
30 30 },
31 31 {
  32 + "path": "pages/categories/product-categories",
  33 + "style": {
  34 + "navigationBarTitleText": "Product Categories",
  35 + "navigationStyle": "custom"
  36 + }
  37 + },
  38 + {
  39 + "path": "pages/categories/label-categories",
  40 + "style": {
  41 + "navigationBarTitleText": "Label Categories",
  42 + "navigationStyle": "custom"
  43 + }
  44 + },
  45 + {
32 46 "path": "pages/labels/food-select",
33 47 "style": {
34 48 "navigationBarTitleText": "Select Food",
... ...
美国版/Food Labeling Management App UniApp/src/pages/categories/label-categories.vue 0 → 100644
  1 +<template>
  2 + <view class="page">
  3 + <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }">
  4 + <view class="top-bar">
  5 + <view class="top-left" @click="goBack">
  6 + <AppIcon name="chevronLeft" size="sm" color="white" />
  7 + </view>
  8 + <view class="top-center">
  9 + <text class="title">{{ t('categories.labelTitle') }}</text>
  10 + </view>
  11 + <view class="top-right" />
  12 + </view>
  13 + </view>
  14 +
  15 + <view class="search-box">
  16 + <view class="search-icon-wrap">
  17 + <AppIcon name="search" size="sm" color="gray" />
  18 + </view>
  19 + <input
  20 + v-model="searchInput"
  21 + class="search-input"
  22 + :placeholder="t('categories.searchPlaceholder')"
  23 + placeholder-class="placeholder"
  24 + />
  25 + </view>
  26 +
  27 + <scroll-view class="list-wrap" scroll-y @scrolltolower="loadMore">
  28 + <view v-if="loading && items.length === 0" class="state">
  29 + <text class="state-text">{{ t('categories.loading') }}</text>
  30 + </view>
  31 + <view v-else-if="errorText" class="state">
  32 + <text class="state-text">{{ errorText }}</text>
  33 + </view>
  34 + <view v-else-if="items.length === 0" class="state">
  35 + <text class="state-text">{{ t('categories.empty') }}</text>
  36 + </view>
  37 + <view v-else class="cards">
  38 + <view v-for="row in items" :key="row.id" class="card">
  39 + <image
  40 + v-if="photoUrl(row.categoryPhotoUrl)"
  41 + :src="photoUrl(row.categoryPhotoUrl)"
  42 + class="card-img"
  43 + mode="aspectFill"
  44 + />
  45 + <view v-else class="card-img card-img-ph">
  46 + <AppIcon name="tag" size="md" color="gray" />
  47 + </view>
  48 + <view class="card-body">
  49 + <text class="card-name">{{ row.categoryName }}</text>
  50 + <text class="card-code">{{ row.categoryCode }}</text>
  51 + <view class="card-meta">
  52 + <text class="badge" :class="row.state ? 'on' : 'off'">
  53 + {{ row.state ? t('categories.active') : t('categories.inactive') }}
  54 + </text>
  55 + <text v-if="row.lastEdited" class="edited">{{ row.lastEdited }}</text>
  56 + </view>
  57 + </view>
  58 + </view>
  59 + </view>
  60 + <view v-if="loading && items.length > 0" class="footer-loading">
  61 + <text class="state-text">{{ t('categories.loading') }}</text>
  62 + </view>
  63 + <view v-else-if="hasMorePage && items.length > 0" class="footer-more">
  64 + <text class="hint">{{ t('categories.pullMore') }}</text>
  65 + </view>
  66 + </scroll-view>
  67 + </view>
  68 +</template>
  69 +
  70 +<script setup lang="ts">
  71 +import { ref, watch, computed } from 'vue'
  72 +import { onShow } from '@dcloudio/uni-app'
  73 +import { useI18n } from 'vue-i18n'
  74 +import AppIcon from '../../components/AppIcon.vue'
  75 +import { getStatusBarHeight } from '../../utils/statusBar'
  76 +import { getAccessToken } from '../../utils/authSession'
  77 +import { fetchLabelCategoryPage } from '../../services/labelCategory'
  78 +import type { LabelCategoryListItemDto } from '../../types/platformCategories'
  79 +import { resolveMediaUrlForApp } from '../../utils/resolveMediaUrl'
  80 +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
  81 +
  82 +const { t } = useI18n()
  83 +const statusBarHeight = getStatusBarHeight()
  84 +const PAGE = 50
  85 +
  86 +const searchInput = ref('')
  87 +const debouncedKeyword = ref('')
  88 +const items = ref<LabelCategoryListItemDto[]>([])
  89 +const totalCount = ref(0)
  90 +const loading = ref(false)
  91 +const errorText = ref('')
  92 +let searchTimer: ReturnType<typeof setTimeout> | null = null
  93 +
  94 +const hasMorePage = computed(() => items.value.length < totalCount.value)
  95 +const nextApiPage = ref(1)
  96 +
  97 +watch(searchInput, () => {
  98 + if (searchTimer) clearTimeout(searchTimer)
  99 + searchTimer = setTimeout(() => {
  100 + debouncedKeyword.value = searchInput.value.trim()
  101 + }, 350)
  102 +})
  103 +
  104 +watch(debouncedKeyword, () => {
  105 + resetAndLoad()
  106 +})
  107 +
  108 +onShow(() => {
  109 + if (!getAccessToken()) {
  110 + uni.reLaunch({ url: '/pages/login/login' })
  111 + return
  112 + }
  113 + resetAndLoad()
  114 +})
  115 +
  116 +function photoUrl(u: string | null | undefined) {
  117 + return resolveMediaUrlForApp(u)
  118 +}
  119 +
  120 +function goBack() {
  121 + const pages = getCurrentPages()
  122 + if (pages.length > 1) uni.navigateBack()
  123 + else uni.redirectTo({ url: '/pages/index/index' })
  124 +}
  125 +
  126 +async function resetAndLoad() {
  127 + items.value = []
  128 + totalCount.value = 0
  129 + nextApiPage.value = 1
  130 + await fetchPage(true)
  131 +}
  132 +
  133 +async function fetchPage(replace: boolean) {
  134 + loading.value = true
  135 + errorText.value = ''
  136 + const page = nextApiPage.value
  137 + try {
  138 + const { items: rows, totalCount: tc } = await fetchLabelCategoryPage({
  139 + skipCount: page,
  140 + maxResultCount: PAGE,
  141 + keyword: debouncedKeyword.value || undefined,
  142 + state: true,
  143 + })
  144 + totalCount.value = tc
  145 + if (replace) items.value = rows
  146 + else items.value = items.value.concat(rows)
  147 + nextApiPage.value = page + 1
  148 + } catch (e: unknown) {
  149 + if (isUsAppSessionExpiredError(e)) return
  150 + errorText.value = e instanceof Error ? e.message : t('categories.loadFailed')
  151 + if (replace) items.value = []
  152 + } finally {
  153 + loading.value = false
  154 + }
  155 +}
  156 +
  157 +async function loadMore() {
  158 + if (loading.value || !hasMorePage.value) return
  159 + await fetchPage(false)
  160 +}
  161 +</script>
  162 +
  163 +<style scoped>
  164 +.page {
  165 + min-height: 100vh;
  166 + background: #f9fafb;
  167 + display: flex;
  168 + flex-direction: column;
  169 +}
  170 +.header-hero {
  171 + background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-dark));
  172 + padding: 16rpx 32rpx 24rpx;
  173 +}
  174 +.top-bar {
  175 + height: 88rpx;
  176 + display: flex;
  177 + align-items: center;
  178 + justify-content: space-between;
  179 +}
  180 +.top-left,
  181 +.top-right {
  182 + width: 64rpx;
  183 + height: 64rpx;
  184 + border-radius: 999rpx;
  185 + background: rgba(255, 255, 255, 0.15);
  186 + display: flex;
  187 + align-items: center;
  188 + justify-content: center;
  189 +}
  190 +.top-center {
  191 + flex: 1;
  192 + text-align: center;
  193 +}
  194 +.title {
  195 + font-size: 32rpx;
  196 + font-weight: 600;
  197 + color: #fff;
  198 +}
  199 +.search-box {
  200 + position: relative;
  201 + padding: 20rpx 24rpx;
  202 + background: #fff;
  203 + border-bottom: 1rpx solid #e5e7eb;
  204 +}
  205 +.search-icon-wrap {
  206 + position: absolute;
  207 + left: 44rpx;
  208 + top: 50%;
  209 + transform: translateY(-50%);
  210 + z-index: 1;
  211 +}
  212 +.search-input {
  213 + height: 72rpx;
  214 + padding-left: 64rpx;
  215 + padding-right: 20rpx;
  216 + background: #f3f4f6;
  217 + border-radius: 16rpx;
  218 + font-size: 26rpx;
  219 +}
  220 +.list-wrap {
  221 + flex: 1;
  222 + height: 0;
  223 + min-height: 400rpx;
  224 +}
  225 +.state {
  226 + padding: 80rpx 32rpx;
  227 + text-align: center;
  228 +}
  229 +.state-text {
  230 + font-size: 28rpx;
  231 + color: #9ca3af;
  232 +}
  233 +.cards {
  234 + padding: 16rpx 24rpx 48rpx;
  235 + display: flex;
  236 + flex-direction: column;
  237 + gap: 16rpx;
  238 +}
  239 +.card {
  240 + display: flex;
  241 + flex-direction: row;
  242 + align-items: center;
  243 + gap: 20rpx;
  244 + background: #fff;
  245 + border-radius: 16rpx;
  246 + padding: 20rpx;
  247 + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
  248 +}
  249 +.card-img {
  250 + width: 96rpx;
  251 + height: 96rpx;
  252 + border-radius: 12rpx;
  253 + flex-shrink: 0;
  254 +}
  255 +.card-img-ph {
  256 + background: #f3f4f6;
  257 + display: flex;
  258 + align-items: center;
  259 + justify-content: center;
  260 +}
  261 +.card-body {
  262 + flex: 1;
  263 + min-width: 0;
  264 +}
  265 +.card-name {
  266 + font-size: 30rpx;
  267 + font-weight: 600;
  268 + color: #111827;
  269 + display: block;
  270 +}
  271 +.card-code {
  272 + font-size: 24rpx;
  273 + color: #6b7280;
  274 + display: block;
  275 + margin-top: 6rpx;
  276 +}
  277 +.card-meta {
  278 + display: flex;
  279 + flex-wrap: wrap;
  280 + align-items: center;
  281 + gap: 12rpx;
  282 + margin-top: 10rpx;
  283 +}
  284 +.badge {
  285 + font-size: 22rpx;
  286 + padding: 4rpx 12rpx;
  287 + border-radius: 8rpx;
  288 +}
  289 +.badge.on {
  290 + background: #dcfce7;
  291 + color: #166534;
  292 +}
  293 +.badge.off {
  294 + background: #fee2e2;
  295 + color: #991b1b;
  296 +}
  297 +.edited {
  298 + font-size: 22rpx;
  299 + color: #9ca3af;
  300 +}
  301 +.footer-loading,
  302 +.footer-more {
  303 + padding: 24rpx;
  304 + text-align: center;
  305 +}
  306 +.hint {
  307 + font-size: 24rpx;
  308 + color: #9ca3af;
  309 +}
  310 +</style>
... ...
美国版/Food Labeling Management App UniApp/src/pages/categories/product-categories.vue 0 → 100644
  1 +<template>
  2 + <view class="page">
  3 + <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }">
  4 + <view class="top-bar">
  5 + <view class="top-left" @click="goBack">
  6 + <AppIcon name="chevronLeft" size="sm" color="white" />
  7 + </view>
  8 + <view class="top-center">
  9 + <text class="title">{{ t('categories.productTitle') }}</text>
  10 + </view>
  11 + <view class="top-right" />
  12 + </view>
  13 + </view>
  14 +
  15 + <view class="search-box">
  16 + <view class="search-icon-wrap">
  17 + <AppIcon name="search" size="sm" color="gray" />
  18 + </view>
  19 + <input
  20 + v-model="searchInput"
  21 + class="search-input"
  22 + :placeholder="t('categories.searchPlaceholder')"
  23 + placeholder-class="placeholder"
  24 + />
  25 + </view>
  26 +
  27 + <scroll-view class="list-wrap" scroll-y @scrolltolower="loadMore">
  28 + <view v-if="loading && items.length === 0" class="state">
  29 + <text class="state-text">{{ t('categories.loading') }}</text>
  30 + </view>
  31 + <view v-else-if="errorText" class="state">
  32 + <text class="state-text">{{ errorText }}</text>
  33 + </view>
  34 + <view v-else-if="items.length === 0" class="state">
  35 + <text class="state-text">{{ t('categories.empty') }}</text>
  36 + </view>
  37 + <view v-else class="cards">
  38 + <view v-for="row in items" :key="row.id" class="card">
  39 + <image
  40 + v-if="photoUrl(row.categoryPhotoUrl)"
  41 + :src="photoUrl(row.categoryPhotoUrl)"
  42 + class="card-img"
  43 + mode="aspectFill"
  44 + />
  45 + <view v-else class="card-img card-img-ph">
  46 + <AppIcon name="food" size="md" color="gray" />
  47 + </view>
  48 + <view class="card-body">
  49 + <text class="card-name">{{ row.categoryName }}</text>
  50 + <text class="card-code">{{ row.categoryCode }}</text>
  51 + <view class="card-meta">
  52 + <text class="badge" :class="row.state ? 'on' : 'off'">
  53 + {{ row.state ? t('categories.active') : t('categories.inactive') }}
  54 + </text>
  55 + <text v-if="row.lastEdited" class="edited">{{ row.lastEdited }}</text>
  56 + </view>
  57 + </view>
  58 + </view>
  59 + </view>
  60 + <view v-if="loading && items.length > 0" class="footer-loading">
  61 + <text class="state-text">{{ t('categories.loading') }}</text>
  62 + </view>
  63 + <view v-else-if="hasMorePage && items.length > 0" class="footer-more">
  64 + <text class="hint">{{ t('categories.pullMore') }}</text>
  65 + </view>
  66 + </scroll-view>
  67 + </view>
  68 +</template>
  69 +
  70 +<script setup lang="ts">
  71 +import { ref, watch, computed } from 'vue'
  72 +import { onShow } from '@dcloudio/uni-app'
  73 +import { useI18n } from 'vue-i18n'
  74 +import AppIcon from '../../components/AppIcon.vue'
  75 +import { getStatusBarHeight } from '../../utils/statusBar'
  76 +import { getAccessToken } from '../../utils/authSession'
  77 +import { fetchProductCategoryPage } from '../../services/productCategory'
  78 +import type { ProductCategoryListItemDto } from '../../types/platformCategories'
  79 +import { resolveMediaUrlForApp } from '../../utils/resolveMediaUrl'
  80 +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
  81 +
  82 +const { t } = useI18n()
  83 +const statusBarHeight = getStatusBarHeight()
  84 +const PAGE = 50
  85 +
  86 +const searchInput = ref('')
  87 +const debouncedKeyword = ref('')
  88 +const items = ref<ProductCategoryListItemDto[]>([])
  89 +const totalCount = ref(0)
  90 +const loading = ref(false)
  91 +const errorText = ref('')
  92 +let searchTimer: ReturnType<typeof setTimeout> | null = null
  93 +
  94 +const hasMorePage = computed(() => items.value.length < totalCount.value)
  95 +/** 下一请求页码(SkipCount,从 1 起) */
  96 +const nextApiPage = ref(1)
  97 +
  98 +watch(searchInput, () => {
  99 + if (searchTimer) clearTimeout(searchTimer)
  100 + searchTimer = setTimeout(() => {
  101 + debouncedKeyword.value = searchInput.value.trim()
  102 + }, 350)
  103 +})
  104 +
  105 +watch(debouncedKeyword, () => {
  106 + resetAndLoad()
  107 +})
  108 +
  109 +onShow(() => {
  110 + if (!getAccessToken()) {
  111 + uni.reLaunch({ url: '/pages/login/login' })
  112 + return
  113 + }
  114 + resetAndLoad()
  115 +})
  116 +
  117 +function photoUrl(u: string | null | undefined) {
  118 + return resolveMediaUrlForApp(u)
  119 +}
  120 +
  121 +function goBack() {
  122 + const pages = getCurrentPages()
  123 + if (pages.length > 1) uni.navigateBack()
  124 + else uni.redirectTo({ url: '/pages/index/index' })
  125 +}
  126 +
  127 +async function resetAndLoad() {
  128 + items.value = []
  129 + totalCount.value = 0
  130 + nextApiPage.value = 1
  131 + await fetchPage(true)
  132 +}
  133 +
  134 +async function fetchPage(replace: boolean) {
  135 + loading.value = true
  136 + errorText.value = ''
  137 + const page = nextApiPage.value
  138 + try {
  139 + const { items: rows, totalCount: tc } = await fetchProductCategoryPage({
  140 + skipCount: page,
  141 + maxResultCount: PAGE,
  142 + keyword: debouncedKeyword.value || undefined,
  143 + state: true,
  144 + })
  145 + totalCount.value = tc
  146 + if (replace) items.value = rows
  147 + else items.value = items.value.concat(rows)
  148 + nextApiPage.value = page + 1
  149 + } catch (e: unknown) {
  150 + if (isUsAppSessionExpiredError(e)) return
  151 + errorText.value = e instanceof Error ? e.message : t('categories.loadFailed')
  152 + if (replace) items.value = []
  153 + } finally {
  154 + loading.value = false
  155 + }
  156 +}
  157 +
  158 +async function loadMore() {
  159 + if (loading.value || !hasMorePage.value) return
  160 + await fetchPage(false)
  161 +}
  162 +</script>
  163 +
  164 +<style scoped>
  165 +.page {
  166 + min-height: 100vh;
  167 + background: #f9fafb;
  168 + display: flex;
  169 + flex-direction: column;
  170 +}
  171 +.header-hero {
  172 + background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-dark));
  173 + padding: 16rpx 32rpx 24rpx;
  174 +}
  175 +.top-bar {
  176 + height: 88rpx;
  177 + display: flex;
  178 + align-items: center;
  179 + justify-content: space-between;
  180 +}
  181 +.top-left,
  182 +.top-right {
  183 + width: 64rpx;
  184 + height: 64rpx;
  185 + border-radius: 999rpx;
  186 + background: rgba(255, 255, 255, 0.15);
  187 + display: flex;
  188 + align-items: center;
  189 + justify-content: center;
  190 +}
  191 +.top-center {
  192 + flex: 1;
  193 + text-align: center;
  194 +}
  195 +.title {
  196 + font-size: 32rpx;
  197 + font-weight: 600;
  198 + color: #fff;
  199 +}
  200 +.search-box {
  201 + position: relative;
  202 + padding: 20rpx 24rpx;
  203 + background: #fff;
  204 + border-bottom: 1rpx solid #e5e7eb;
  205 +}
  206 +.search-icon-wrap {
  207 + position: absolute;
  208 + left: 44rpx;
  209 + top: 50%;
  210 + transform: translateY(-50%);
  211 + z-index: 1;
  212 +}
  213 +.search-input {
  214 + height: 72rpx;
  215 + padding-left: 64rpx;
  216 + padding-right: 20rpx;
  217 + background: #f3f4f6;
  218 + border-radius: 16rpx;
  219 + font-size: 26rpx;
  220 +}
  221 +.list-wrap {
  222 + flex: 1;
  223 + height: 0;
  224 + min-height: 400rpx;
  225 +}
  226 +.state {
  227 + padding: 80rpx 32rpx;
  228 + text-align: center;
  229 +}
  230 +.state-text {
  231 + font-size: 28rpx;
  232 + color: #9ca3af;
  233 +}
  234 +.cards {
  235 + padding: 16rpx 24rpx 48rpx;
  236 + display: flex;
  237 + flex-direction: column;
  238 + gap: 16rpx;
  239 +}
  240 +.card {
  241 + display: flex;
  242 + flex-direction: row;
  243 + align-items: center;
  244 + gap: 20rpx;
  245 + background: #fff;
  246 + border-radius: 16rpx;
  247 + padding: 20rpx;
  248 + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
  249 +}
  250 +.card-img {
  251 + width: 96rpx;
  252 + height: 96rpx;
  253 + border-radius: 12rpx;
  254 + flex-shrink: 0;
  255 +}
  256 +.card-img-ph {
  257 + background: #f3f4f6;
  258 + display: flex;
  259 + align-items: center;
  260 + justify-content: center;
  261 +}
  262 +.card-body {
  263 + flex: 1;
  264 + min-width: 0;
  265 +}
  266 +.card-name {
  267 + font-size: 30rpx;
  268 + font-weight: 600;
  269 + color: #111827;
  270 + display: block;
  271 +}
  272 +.card-code {
  273 + font-size: 24rpx;
  274 + color: #6b7280;
  275 + display: block;
  276 + margin-top: 6rpx;
  277 +}
  278 +.card-meta {
  279 + display: flex;
  280 + flex-wrap: wrap;
  281 + align-items: center;
  282 + gap: 12rpx;
  283 + margin-top: 10rpx;
  284 +}
  285 +.badge {
  286 + font-size: 22rpx;
  287 + padding: 4rpx 12rpx;
  288 + border-radius: 8rpx;
  289 +}
  290 +.badge.on {
  291 + background: #dcfce7;
  292 + color: #166534;
  293 +}
  294 +.badge.off {
  295 + background: #fee2e2;
  296 + color: #991b1b;
  297 +}
  298 +.edited {
  299 + font-size: 22rpx;
  300 + color: #9ca3af;
  301 +}
  302 +.footer-loading,
  303 +.footer-more {
  304 + padding: 24rpx;
  305 + text-align: center;
  306 +}
  307 +.hint {
  308 + font-size: 24rpx;
  309 + color: #9ca3af;
  310 +}
  311 +</style>
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
... ... @@ -29,87 +29,141 @@
29 29  
30 30 <view class="body">
31 31 <view class="sidebar">
  32 + <view v-if="listLoading && labelTree.length === 0" class="sidebar-loading">
  33 + <text class="sidebar-loading-text">Loading…</text>
  34 + </view>
32 35 <view
33   - v-for="cat in labelCategories"
  36 + v-for="cat in labelTree"
34 37 :key="cat.id"
35 38 class="cat-item"
36   - :class="{ active: selectedCategory === cat.id }"
37   - @click="selectedCategory = cat.id"
  39 + :class="{ active: selectedCategoryId === cat.id }"
  40 + @click="selectedCategoryId = cat.id"
38 41 >
39   - <view v-if="selectedCategory === cat.id" class="active-bar" />
40   - <view class="cat-icon" :class="cat.bgClass">
41   - <AppIcon :name="cat.icon" size="sm" :color="selectedCategory === cat.id ? 'white' : cat.iconColor" />
  42 + <view v-if="selectedCategoryId === cat.id" class="active-bar" />
  43 + <view
  44 + class="cat-icon"
  45 + :class="sidebarPhotoSrc(cat) ? 'cat-icon--photo' : 'cat-icon--fallback'"
  46 + >
  47 + <image
  48 + v-if="sidebarPhotoSrc(cat)"
  49 + :src="sidebarPhotoSrc(cat)"
  50 + class="cat-photo"
  51 + mode="aspectFill"
  52 + />
  53 + <AppIcon
  54 + v-else
  55 + :name="sidebarIconForCategory(cat)"
  56 + size="sm"
  57 + :color="sidebarFallbackIconColor(cat)"
  58 + />
42 59 </view>
43   - <text class="cat-name">{{ cat.name }}</text>
  60 + <text class="cat-name">{{ cat.categoryName }}</text>
44 61 </view>
45 62 </view>
46 63  
47 64 <scroll-view class="main-panel" scroll-y>
48 65 <view class="panel-inner">
49   - <view class="search-box">
50   - <view class="search-icon-wrap">
51   - <AppIcon name="search" size="sm" color="gray" />
52   - </view>
53   - <input
54   - v-model="searchTerm"
55   - class="search-input"
56   - placeholder="Search products..."
57   - placeholder-class="placeholder"
58   - />
  66 + <view v-if="!locationId" class="empty">
  67 + <AppIcon name="mapPin" size="lg" color="gray" />
  68 + <text class="empty-text">Select a store to load labels.</text>
59 69 </view>
60 70  
61   - <view v-if="filteredProductCategories.length === 0" class="empty">
62   - <AppIcon name="search" size="lg" color="gray" />
63   - <text class="empty-text">No products found</text>
64   - </view>
  71 + <template v-else>
  72 + <view class="search-box">
  73 + <view class="search-icon-wrap">
  74 + <AppIcon name="search" size="sm" color="gray" />
  75 + </view>
  76 + <input
  77 + v-model="searchTerm"
  78 + class="search-input"
  79 + placeholder="Search products..."
  80 + placeholder-class="placeholder"
  81 + />
  82 + </view>
65 83  
66   - <view v-else class="category-list">
67   - <view
68   - v-for="pCat in filteredProductCategories"
69   - :key="pCat.id"
70   - class="cat-section"
71   - >
72   - <view class="cat-header" @click="toggleCategory(pCat.id)">
73   - <view class="cat-header-left">
74   - <view class="cat-header-icon" :class="pCat.colorClass">
75   - <AppIcon :name="pCat.icon" size="sm" :color="pCat.iconColor" />
76   - </view>
77   - <view class="cat-header-info">
78   - <text class="cat-header-name">{{ pCat.name }}</text>
79   - <text class="cat-header-count">{{ pCat.products.length }} items</text>
  84 + <view v-if="listLoading" class="empty">
  85 + <text class="empty-text">Loading…</text>
  86 + </view>
  87 +
  88 + <view v-else-if="listError" class="empty">
  89 + <text class="empty-text">{{ listError }}</text>
  90 + </view>
  91 +
  92 + <view v-else-if="filteredProductCategories.length === 0" class="empty">
  93 + <AppIcon name="search" size="lg" color="gray" />
  94 + <text class="empty-text">No products found</text>
  95 + </view>
  96 +
  97 + <view v-else class="category-list">
  98 + <view
  99 + v-for="(pCat, pIdx) in filteredProductCategories"
  100 + :key="productCategoryRowKey(pCat, pIdx)"
  101 + class="cat-section"
  102 + >
  103 + <view class="cat-header" @click="toggleCategory(productCategoryRowKey(pCat, pIdx))">
  104 + <view class="cat-header-left">
  105 + <view v-if="productCategoryPhotoSrc(pCat)" class="cat-header-thumb">
  106 + <image
  107 + :src="productCategoryPhotoSrc(pCat)"
  108 + class="cat-header-img"
  109 + mode="aspectFill"
  110 + />
  111 + </view>
  112 + <view v-else class="cat-header-icon" :class="colorClassForName(pCat.name)">
  113 + <AppIcon name="food" size="sm" color="white" />
  114 + </view>
  115 + <view class="cat-header-info">
  116 + <text class="cat-header-name">{{ pCat.name }}</text>
  117 + <text class="cat-header-count">{{ displayProductCategoryItemCount(pCat) }} items</text>
  118 + </view>
80 119 </view>
  120 + <AppIcon
  121 + :name="
  122 + expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0
  123 + ? 'chevronUp'
  124 + : 'chevronDown'
  125 + "
  126 + size="sm"
  127 + color="gray"
  128 + />
81 129 </view>
82   - <AppIcon
83   - :name="expandedCategories.indexOf(pCat.id) >= 0 ? 'chevronUp' : 'chevronDown'"
84   - size="sm"
85   - color="gray"
86   - />
87   - </view>
88 130  
89   - <view v-if="expandedCategories.indexOf(pCat.id) >= 0" class="cat-foods">
90   - <view class="food-grid">
91   - <view
92   - v-for="product in pCat.products"
93   - :key="product.id"
94   - class="food-card"
95   - @click="handleProductClick(product)"
96   - >
97   - <view class="food-img-wrap">
98   - <image :src="product.image" class="food-img" mode="aspectFill" />
99   - <view class="size-badge">
100   - <text class="size-badge-text">{{ product.templateSize }}</text>
101   - </view>
102   - <view v-if="product.labelTypes.length > 0" class="type-badge">
103   - <text class="type-badge-text">{{ product.labelTypes.length }} Types</text>
  131 + <view
  132 + v-if="expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0"
  133 + class="cat-foods"
  134 + >
  135 + <view class="food-grid">
  136 + <view
  137 + v-for="product in pCat.products"
  138 + :key="product.productId"
  139 + class="food-card"
  140 + @click="handleProductClick(product, pCat.name)"
  141 + >
  142 + <view class="food-img-wrap">
  143 + <image
  144 + v-if="productPhotoSrc(product)"
  145 + :src="productPhotoSrc(product)"
  146 + class="food-img"
  147 + mode="aspectFill"
  148 + />
  149 + <view v-else class="food-thumb-placeholder">
  150 + <AppIcon name="layers" size="md" color="gray" />
  151 + </view>
  152 + <view class="size-badge">
  153 + <text class="size-badge-text">{{ primaryLabelSizeText(product) }}</text>
  154 + </view>
  155 + <view v-if="effectiveLabelTypeCount(product) > 0" class="type-badge">
  156 + <text class="type-badge-text">{{ effectiveLabelTypeCount(product) }} Types</text>
  157 + </view>
104 158 </view>
  159 + <text class="food-name">{{ product.productName }}</text>
  160 + <text class="food-desc">{{ product.subtitle || '—' }}</text>
105 161 </view>
106   - <text class="food-name">{{ getDisplayName(product) }}</text>
107   - <text class="food-desc">{{ product.templateName }}</text>
108 162 </view>
109 163 </view>
110 164 </view>
111 165 </view>
112   - </view>
  166 + </template>
113 167 </view>
114 168 </scroll-view>
115 169 </view>
... ... @@ -117,15 +171,15 @@
117 171 <view v-if="showSubTypeModal" class="modal-mask" @click="showSubTypeModal = false">
118 172 <view class="modal-body" @click.stop>
119 173 <text class="modal-title">Select Label Type</text>
120   - <text class="modal-desc">{{ selectedProduct ? getDisplayName(selectedProduct) : '' }}</text>
  174 + <text class="modal-desc">{{ selectedProduct ? selectedProduct.productName : '' }}</text>
121 175 <view class="subtype-list">
122 176 <view
123 177 v-for="lt in currentLabelTypes"
124   - :key="lt.id"
  178 + :key="lt.labelTypeId"
125 179 class="subtype-item"
126 180 @click="selectLabelType(lt)"
127 181 >
128   - <text class="subtype-name">{{ lt.name }}</text>
  182 + <text class="subtype-name">{{ lt.typeName }}</text>
129 183 <AppIcon name="chevronRight" size="sm" color="gray" />
130 184 </view>
131 185 </view>
... ... @@ -140,134 +194,210 @@
140 194 </template>
141 195  
142 196 <script setup lang="ts">
143   -import { ref, computed } from 'vue'
  197 +import { ref, computed, watch } from 'vue'
144 198 import { onShow } from '@dcloudio/uni-app'
145 199 import AppIcon from '../../components/AppIcon.vue'
146 200 import SideMenu from '../../components/SideMenu.vue'
147 201 import LocationPicker from '../../components/LocationPicker.vue'
148 202 import { getStatusBarHeight } from '../../utils/statusBar'
149 203 import { getCurrentPrinterSummary } from '../../utils/print/manager/printerManager'
  204 +import { getCurrentStoreId } from '../../utils/stores'
  205 +import { fetchUsAppLabelingTree } from '../../services/usAppLabeling'
  206 +import type {
  207 + UsAppLabelCategoryTreeNodeDto,
  208 + UsAppLabelingProductNodeDto,
  209 + UsAppLabelTypeNodeDto,
  210 + UsAppProductCategoryNodeDto,
  211 +} from '../../types/usAppLabeling'
  212 +import { resolveMediaUrlForApp } from '../../utils/resolveMediaUrl'
  213 +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
150 214  
151 215 const statusBarHeight = getStatusBarHeight()
152 216 const isMenuOpen = ref(false)
153   -const selectedCategory = ref('prep')
  217 +const locationId = ref(getCurrentStoreId())
  218 +const selectedCategoryId = ref('')
154 219 const searchTerm = ref('')
  220 +const debouncedSearch = ref('')
155 221 const expandedCategories = ref<string[]>([])
156 222 const showSubTypeModal = ref(false)
157   -const selectedProduct = ref<any>(null)
158   -const currentLabelTypes = ref<any[]>([])
  223 +const selectedProduct = ref<UsAppLabelingProductNodeDto | null>(null)
  224 +const currentLabelTypes = ref<UsAppLabelTypeNodeDto[]>([])
  225 +
  226 +const labelTree = ref<UsAppLabelCategoryTreeNodeDto[]>([])
  227 +const listLoading = ref(false)
  228 +const listError = ref('')
159 229  
160 230 const btConnected = ref(false)
161 231 const btDeviceName = ref('')
162 232  
  233 +let searchTimer: ReturnType<typeof setTimeout> | null = null
  234 +
  235 +watch(searchTerm, () => {
  236 + if (searchTimer) clearTimeout(searchTimer)
  237 + searchTimer = setTimeout(() => {
  238 + debouncedSearch.value = searchTerm.value.trim()
  239 + }, 350)
  240 +})
  241 +
163 242 onShow(() => {
  243 + locationId.value = getCurrentStoreId()
164 244 const summary = getCurrentPrinterSummary()
165 245 btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin'
166 246 btDeviceName.value = summary.displayName || ''
  247 + if (locationId.value) {
  248 + loadTree()
  249 + }
167 250 })
168 251  
169   -const labelCategories = [
170   - { id: 'prep', name: 'Prep', icon: 'chef', iconColor: 'green' as const, bgClass: 'green' },
171   - { id: 'grabngo', name: "Grab'n'Go", icon: 'package', iconColor: 'orange' as const, bgClass: 'orange' },
172   -]
173   -
174   -interface LabelType {
175   - id: string
176   - name: string
177   - desc: string
178   -}
179   -
180   -interface Product {
181   - id: string
182   - name: string
183   - image: string
184   - templateSize: string
185   - templateName: string
186   - lastEdited: string
187   - labelTypes: LabelType[]
188   -}
189   -
190   -interface ProductCategory {
191   - id: string
192   - name: string
193   - icon: string
194   - iconColor: 'blue' | 'orange' | 'green' | 'purple' | 'red' | 'gray'
195   - colorClass: string
196   - products: Product[]
197   -}
198   -
199   -const productCategoriesByLabel: Record<string, ProductCategory[]> = {
200   - prep: [
201   - {
202   - id: 'meat',
203   - name: 'Meat',
204   - icon: 'food',
205   - iconColor: 'red',
206   - colorClass: 'bg-red',
207   - products: [
208   - {
209   - id: 'chicken',
210   - name: 'Chicken',
211   - image: 'https://images.unsplash.com/photo-1532550907401-a500c9a57435?w=400',
212   - templateSize: '2"x2"',
213   - templateName: 'Basic',
214   - lastEdited: '2025.12.03 11:45',
215   - labelTypes: [
216   - { id: 'defrost', name: 'Defrost', desc: 'For defrosted items, tracks thaw date' },
217   - { id: 'opened', name: 'Opened/Preped', desc: 'For opened or prepared items' },
218   - { id: 'heated', name: 'Heated', desc: 'For heated/cooked items' },
219   - ],
220   - },
221   - ],
222   - },
223   - ],
224   - grabngo: [
225   - {
226   - id: 'sandwich',
227   - name: 'Sandwich',
228   - icon: 'food',
229   - iconColor: 'orange',
230   - colorClass: 'bg-orange',
231   - products: [
232   - {
233   - id: 'chicken-sandwich',
234   - name: 'Chicken Sandwich',
235   - image: 'https://images.unsplash.com/photo-1553909489-cd47e0907980?w=400',
236   - templateSize: '2"x6"',
237   - templateName: "G'n'G",
238   - lastEdited: '2025.12.03 11:45',
239   - labelTypes: [],
240   - },
241   - ],
242   - },
243   - ],
244   -}
245   -
246   -// 按详情页规则:chicken→Chicken,2"x6"/2"x4"→Cheese Burger Deluxe,其余→Syrup(图片仍用产品图)
247   -function getDisplayName(product: Product): string {
248   - if (product.id === 'chicken') return 'Chicken'
249   - const size = product.templateSize || ''
250   - if (size.indexOf('2"x6"') >= 0 || size.indexOf('2"x4"') >= 0) return 'Cheese Burger Deluxe'
251   - return 'Syrup'
252   -}
  252 +const selectedLabelCategory = computed(() =>
  253 + labelTree.value.find((c) => c.id === selectedCategoryId.value)
  254 +)
  255 +
  256 +const productCategoriesForSidebar = computed<UsAppProductCategoryNodeDto[]>(() => {
  257 + return selectedLabelCategory.value?.productCategories ?? []
  258 +})
253 259  
254 260 const filteredProductCategories = computed(() => {
255   - const cats = productCategoriesByLabel[selectedCategory.value] || []
256   - const s = searchTerm.value.toLowerCase()
  261 + const cats = productCategoriesForSidebar.value
  262 + const s = debouncedSearch.value.toLowerCase()
257 263 if (!s) return cats
258 264 return cats
259   - .map(function (cat) {
260   - return {
261   - ...cat,
262   - products: cat.products.filter(function (p) {
263   - const displayName = getDisplayName(p)
264   - return p.name.toLowerCase().indexOf(s) >= 0 || displayName.toLowerCase().indexOf(s) >= 0
265   - }),
266   - }
  265 + .map((cat) => ({
  266 + ...cat,
  267 + products: cat.products.filter((p) => {
  268 + const hay = `${p.productName} ${p.productCode} ${p.subtitle || ''} ${cat.name}`.toLowerCase()
  269 + return hay.indexOf(s) >= 0
  270 + }),
  271 + }))
  272 + .filter((cat) => cat.products.length > 0)
  273 +})
  274 +
  275 +watch(labelTree, (tree) => {
  276 + if (!tree.length) {
  277 + selectedCategoryId.value = ''
  278 + return
  279 + }
  280 + if (!selectedCategoryId.value || !tree.some((t) => t.id === selectedCategoryId.value)) {
  281 + selectedCategoryId.value = tree[0].id
  282 + }
  283 +})
  284 +
  285 +watch(selectedCategoryId, () => {
  286 + expandedCategories.value = []
  287 +})
  288 +
  289 +watch([selectedLabelCategory, listLoading], () => {
  290 + const cats = selectedLabelCategory.value?.productCategories ?? []
  291 + if (cats.length && expandedCategories.value.length === 0) {
  292 + expandedCategories.value = [productCategoryRowKey(cats[0], 0)]
  293 + }
  294 +})
  295 +
  296 +async function loadTree() {
  297 + const loc = locationId.value
  298 + if (!loc) {
  299 + labelTree.value = []
  300 + return
  301 + }
  302 + listLoading.value = true
  303 + listError.value = ''
  304 + try {
  305 + const kw = debouncedSearch.value.trim()
  306 + const rows = await fetchUsAppLabelingTree({
  307 + locationId: loc,
  308 + keyword: kw || undefined,
267 309 })
268   - .filter(function (cat) { return cat.products.length > 0 })
  310 + labelTree.value = rows
  311 + } catch (e: unknown) {
  312 + if (isUsAppSessionExpiredError(e)) return
  313 + listError.value = e instanceof Error ? e.message : 'Failed to load labeling tree.'
  314 + labelTree.value = []
  315 + } finally {
  316 + listLoading.value = false
  317 + }
  318 +}
  319 +
  320 +watch(debouncedSearch, () => {
  321 + if (locationId.value) loadTree()
269 322 })
270 323  
  324 +function hashString(s: string): number {
  325 + let h = 0
  326 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
  327 + return h
  328 +}
  329 +
  330 +const SIDEBAR_ICON_NAMES = [
  331 + 'food',
  332 + 'chef',
  333 + 'utensils',
  334 + 'package',
  335 + 'layers',
  336 + 'tag',
  337 + 'calendar',
  338 + 'snowflake',
  339 +] as const
  340 +
  341 +function sidebarPhotoSrc(cat: UsAppLabelCategoryTreeNodeDto): string {
  342 + return resolveMediaUrlForApp(cat.categoryPhotoUrl)
  343 +}
  344 +
  345 +function sidebarIconForCategory(cat: UsAppLabelCategoryTreeNodeDto): string {
  346 + const idx = Math.abs(hashString(cat.id || cat.categoryName)) % SIDEBAR_ICON_NAMES.length
  347 + return SIDEBAR_ICON_NAMES[idx]
  348 +}
  349 +
  350 +/** 无 categoryPhotoUrl 时:透明底,仅用线条颜色区分选中 */
  351 +function sidebarFallbackIconColor(cat: UsAppLabelCategoryTreeNodeDto): 'primary' | 'gray' {
  352 + return selectedCategoryId.value === cat.id ? 'primary' : 'gray'
  353 +}
  354 +
  355 +function productCategoryRowKey(p: UsAppProductCategoryNodeDto, index: number): string {
  356 + const id = p.categoryId != null ? String(p.categoryId).trim() : ''
  357 + if (id) return `id:${id}`
  358 + return `name:${p.name}:${index}`
  359 +}
  360 +
  361 +function productCategoryPhotoSrc(p: UsAppProductCategoryNodeDto): string {
  362 + return resolveMediaUrlForApp(p.categoryPhotoUrl ?? null)
  363 +}
  364 +
  365 +function displayProductCategoryItemCount(p: UsAppProductCategoryNodeDto): number {
  366 + const n = typeof p.itemCount === 'number' ? p.itemCount : NaN
  367 + if (!Number.isNaN(n) && n >= 0) return n
  368 + return p.products?.length ?? 0
  369 +}
  370 +
  371 +const COLOR_CLASSES = ['bg-red', 'bg-blue', 'bg-green', 'bg-orange', 'bg-purple']
  372 +
  373 +function colorClassForName(name: string): string {
  374 + let h = 0
  375 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0
  376 + return COLOR_CLASSES[Math.abs(h) % COLOR_CLASSES.length]
  377 +}
  378 +
  379 +function productPhotoSrc(p: UsAppLabelingProductNodeDto): string {
  380 + return resolveMediaUrlForApp(p.productImageUrl)
  381 +}
  382 +
  383 +/** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */
  384 +function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string {
  385 + const types = p.labelTypes || []
  386 + if (types.length === 0) return '—'
  387 + const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean)
  388 + if (texts.length === 0) return '—'
  389 + const first = texts[0]
  390 + if (texts.length === 1) return first
  391 + const allSame = texts.every((x) => x === first)
  392 + return allSame ? first : `${first} …`
  393 +}
  394 +
  395 +function effectiveLabelTypeCount(p: UsAppLabelingProductNodeDto): number {
  396 + const fromArr = p.labelTypes?.length ?? 0
  397 + const fromField = typeof p.labelTypeCount === 'number' ? p.labelTypeCount : 0
  398 + return Math.max(fromArr, fromField)
  399 +}
  400 +
271 401 const toggleCategory = (catId: string) => {
272 402 if (expandedCategories.value.indexOf(catId) >= 0) {
273 403 expandedCategories.value = []
... ... @@ -276,22 +406,50 @@ const toggleCategory = (catId: string) =&gt; {
276 406 }
277 407 }
278 408  
279   -const handleProductClick = (product: Product) => {
280   - if (product.labelTypes.length > 0) {
  409 +const handleProductClick = (product: UsAppLabelingProductNodeDto, _productCategoryName: string) => {
  410 + if (product.labelTypes.length > 1) {
281 411 selectedProduct.value = product
282 412 currentLabelTypes.value = product.labelTypes
283 413 showSubTypeModal.value = true
284   - } else {
285   - goPreview(product.id, '')
  414 + return
  415 + }
  416 + if (product.labelTypes.length === 1) {
  417 + goPreview(product, product.labelTypes[0], _productCategoryName)
  418 + return
286 419 }
  420 + uni.showToast({ title: 'No label types for this product.', icon: 'none' })
287 421 }
288 422  
289   -const selectLabelType = (lt: LabelType) => {
  423 +const selectLabelType = (lt: UsAppLabelTypeNodeDto) => {
290 424 const product = selectedProduct.value
291   - if (product) {
292   - showSubTypeModal.value = false
293   - goPreview(product.id, lt.id)
294   - }
  425 + if (!product) return
  426 + const cat =
  427 + productCategoriesForSidebar.value.find((c) => c.products.some((p) => p.productId === product.productId))
  428 + ?.name || ''
  429 + showSubTypeModal.value = false
  430 + goPreview(product, lt, cat)
  431 +}
  432 +
  433 +function goPreview(
  434 + product: UsAppLabelingProductNodeDto,
  435 + lt: UsAppLabelTypeNodeDto,
  436 + categoryName: string
  437 +) {
  438 + const cat =
  439 + categoryName ||
  440 + productCategoriesForSidebar.value.find((c) => c.products.some((p) => p.productId === product.productId))
  441 + ?.name ||
  442 + ''
  443 + const q = [
  444 + `labelCode=${encodeURIComponent(lt.labelCode)}`,
  445 + `productId=${encodeURIComponent(product.productId)}`,
  446 + `productName=${encodeURIComponent(product.productName)}`,
  447 + `categoryName=${encodeURIComponent(cat)}`,
  448 + `typeName=${encodeURIComponent(lt.typeName)}`,
  449 + `templateSize=${encodeURIComponent(lt.labelSizeText || '')}`,
  450 + `templateName=${encodeURIComponent(lt.templateCode || '')}`,
  451 + ]
  452 + uni.navigateTo({ url: `/pages/labels/preview?${q.join('&')}` })
295 453 }
296 454  
297 455 const goBack = () => {
... ... @@ -306,14 +464,6 @@ const goBack = () =&gt; {
306 464 const goBluetoothPage = () => {
307 465 uni.navigateTo({ url: '/pages/labels/bluetooth' })
308 466 }
309   -
310   -const goPreview = (productId: string, subType: string) => {
311   - let url = '/pages/labels/preview?productId=' + productId
312   - if (subType) {
313   - url += '&subType=' + subType
314   - }
315   - uni.navigateTo({ url })
316   -}
317 467 </script>
318 468  
319 469 <style scoped>
... ... @@ -429,6 +579,16 @@ const goPreview = (productId: string, subType: string) =&gt; {
429 579 overflow-y: auto;
430 580 }
431 581  
  582 +.sidebar-loading {
  583 + padding: 24rpx 12rpx;
  584 + text-align: center;
  585 +}
  586 +
  587 +.sidebar-loading-text {
  588 + font-size: 22rpx;
  589 + color: #9ca3af;
  590 +}
  591 +
432 592 .cat-item {
433 593 display: flex;
434 594 flex-direction: column;
... ... @@ -454,39 +614,28 @@ const goPreview = (productId: string, subType: string) =&gt; {
454 614 .cat-icon {
455 615 width: 64rpx;
456 616 height: 64rpx;
457   - border-radius: 16rpx;
458 617 display: flex;
459 618 align-items: center;
460 619 justify-content: center;
461 620 margin-bottom: 8rpx;
  621 + background: transparent;
462 622 }
463 623  
464   -.cat-icon.green {
465   - background: #f0fdf4;
466   -}
467   -
468   -.cat-icon.orange {
469   - background: #fff7ed;
470   -}
471   -
472   -.cat-icon.red {
473   - background: #fef2f2;
474   -}
475   -
476   -.cat-icon.blue {
477   - background: #eff6ff;
  624 +.cat-icon--photo {
  625 + overflow: hidden;
  626 + border-radius: 14rpx;
478 627 }
479 628  
480   -.cat-icon.cyan {
481   - background: #ecfeff;
  629 +.cat-icon--fallback {
  630 + overflow: visible;
482 631 }
483 632  
484   -.cat-item.active .cat-icon.green,
485   -.cat-item.active .cat-icon.orange,
486   -.cat-item.active .cat-icon.red,
487   -.cat-item.active .cat-icon.blue,
488   -.cat-item.active .cat-icon.cyan {
489   - background: var(--theme-primary);
  633 +.cat-photo {
  634 + width: 100%;
  635 + height: 100%;
  636 + border-radius: 14rpx;
  637 + display: block;
  638 + vertical-align: top;
490 639 }
491 640  
492 641 .cat-name {
... ... @@ -545,6 +694,7 @@ const goPreview = (productId: string, subType: string) =&gt; {
545 694 font-size: 28rpx;
546 695 color: #9ca3af;
547 696 margin-top: 24rpx;
  697 + text-align: center;
548 698 }
549 699  
550 700 .category-list {
... ... @@ -575,6 +725,20 @@ const goPreview = (productId: string, subType: string) =&gt; {
575 725 min-width: 0;
576 726 }
577 727  
  728 +.cat-header-thumb {
  729 + width: 56rpx;
  730 + height: 56rpx;
  731 + border-radius: 14rpx;
  732 + overflow: hidden;
  733 + flex-shrink: 0;
  734 +}
  735 +
  736 +.cat-header-img {
  737 + width: 100%;
  738 + height: 100%;
  739 + display: block;
  740 +}
  741 +
578 742 .cat-header-icon {
579 743 width: 56rpx;
580 744 height: 56rpx;
... ... @@ -586,23 +750,19 @@ const goPreview = (productId: string, subType: string) =&gt; {
586 750 }
587 751  
588 752 .cat-header-icon.bg-red {
589   - background: #fef2f2;
  753 + background: #ef4444;
590 754 }
591   -
592 755 .cat-header-icon.bg-blue {
593   - background: #eff6ff;
  756 + background: #3b82f6;
594 757 }
595   -
596 758 .cat-header-icon.bg-green {
597   - background: #f0fdf4;
  759 + background: #22c55e;
598 760 }
599   -
600 761 .cat-header-icon.bg-orange {
601   - background: #fff7ed;
  762 + background: #f97316;
602 763 }
603   -
604 764 .cat-header-icon.bg-purple {
605   - background: #faf5ff;
  765 + background: #a855f7;
606 766 }
607 767  
608 768 .cat-header-info {
... ... @@ -665,6 +825,18 @@ const goPreview = (productId: string, subType: string) =&gt; {
665 825 height: 100%;
666 826 }
667 827  
  828 +.food-thumb-placeholder {
  829 + position: absolute;
  830 + left: 0;
  831 + top: 0;
  832 + width: 100%;
  833 + height: 100%;
  834 + display: flex;
  835 + align-items: center;
  836 + justify-content: center;
  837 + background: linear-gradient(165deg, #f3f4f6 0%, #e5e7eb 45%, #d1d5db 100%);
  838 +}
  839 +
668 840 .size-badge {
669 841 position: absolute;
670 842 left: 8rpx;
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -16,61 +16,111 @@
16 16 </view>
17 17  
18 18 <view class="content">
19   - <view class="food-card">
20   - <view class="food-info">
21   - <text class="food-name">{{ displayProductName }}</text>
22   - <text class="food-cat">{{ productCategory }}</text>
23   - <view v-if="labelTypeName" class="food-label-type">
24   - <AppIcon name="tag" size="sm" color="primary" />
25   - <text class="food-label-type-text">{{ labelTypeName }}</text>
26   - </view>
27   - </view>
28   - <view class="food-template">
29   - <text class="template-size">{{ templateSize }}</text>
30   - <text class="template-name">{{ templateName }}</text>
31   - </view>
  19 + <view v-if="previewLoading" class="state-block">
  20 + <text class="state-text">Loading preview…</text>
  21 + </view>
  22 + <view v-else-if="previewError" class="state-block">
  23 + <text class="state-text">{{ previewError }}</text>
32 24 </view>
33 25  
34   - <view class="qty-card">
35   - <text class="qty-label">Print Quantity</text>
36   - <view class="qty-control">
37   - <view class="qty-btn" :class="{ disabled: printQty <= 1 }" @click="decrement">
38   - <AppIcon name="minus" size="sm" color="gray" />
  26 + <template v-else>
  27 + <view class="food-card">
  28 + <view class="food-info">
  29 + <text class="food-name">{{ displayProductName }}</text>
  30 + <text class="food-cat">{{ productCategory }}</text>
  31 + <view v-if="labelTypeName" class="food-label-type">
  32 + <AppIcon name="tag" size="sm" color="primary" />
  33 + <text class="food-label-type-text">{{ labelTypeName }}</text>
  34 + </view>
39 35 </view>
40   - <text class="qty-value">{{ printQty }}</text>
41   - <view class="qty-btn" @click="increment">
42   - <AppIcon name="plus" size="sm" color="gray" />
  36 + <view class="food-template">
  37 + <text class="template-size">{{ templateSize }}</text>
  38 + <text class="template-name">{{ templateName }}</text>
43 39 </view>
44 40 </view>
45   - </view>
46 41  
47   - <text class="section-title">Label Preview</text>
  42 + <view class="qty-card">
  43 + <text class="qty-label">Print Quantity</text>
  44 + <view class="qty-control">
  45 + <view class="qty-btn" :class="{ disabled: printQty <= 1 }" @click="decrement">
  46 + <AppIcon name="minus" size="sm" color="gray" />
  47 + </view>
  48 + <text class="qty-value">{{ printQty }}</text>
  49 + <view class="qty-btn" @click="increment">
  50 + <AppIcon name="plus" size="sm" color="gray" />
  51 + </view>
  52 + </view>
  53 + </view>
48 54  
49   - <view class="label-card">
50   - <view class="label-img-wrap">
51   - <image :src="labelImage" class="label-img" mode="widthFix" />
  55 + <view v-if="printOptionFieldList.length" class="print-options-card">
  56 + <text class="print-options-title">Print choices</text>
  57 + <text class="print-options-hint">
  58 + Choose options for each field. Preview updates to show the selected text (no picker on the label).
  59 + </text>
  60 + <view v-for="el in printOptionFieldList" :key="el.id" class="print-option-block">
  61 + <text class="print-option-label">{{ optionFieldTitle(el) }}</text>
  62 + <view class="chip-row">
  63 + <view
  64 + v-for="opt in dictValuesForElement(el.id)"
  65 + :key="el.id + '-' + opt"
  66 + class="chip"
  67 + :class="{ 'chip-on': isOptionPicked(el.id, opt) }"
  68 + @click="togglePrintOption(el.id, opt)"
  69 + >
  70 + <text class="chip-text">{{ opt }}</text>
  71 + </view>
  72 + </view>
  73 + </view>
52 74 </view>
53   - </view>
54 75  
55   - <view class="info-row">
56   - <view class="info-item">
57   - <text class="info-label">Label ID</text>
58   - <text class="info-value">{{ labelId }}</text>
  76 + <view v-if="printFreeFieldList.length" class="print-options-card">
  77 + <text class="print-options-title">Print input</text>
  78 + <text class="print-options-hint">
  79 + with a box below. If the template has a unit, it is appended on the label after what you type.
  80 + </text>
  81 + <view v-for="el in printFreeFieldList" :key="'free-' + el.id" class="print-option-block">
  82 + <text class="print-option-label">{{ freeFieldNameLabel(el) }}</text>
  83 + <input
  84 + class="free-field-input"
  85 + type="text"
  86 + :value="printFreeFieldValues[el.id] ?? ''"
  87 + :placeholder="freeFieldPlaceholder(el)"
  88 + @input="onFreeFieldInput(el.id, $event)"
  89 + />
  90 + </view>
59 91 </view>
60   - <view class="info-item">
61   - <text class="info-label">Last Edited</text>
62   - <text class="info-value">{{ lastEdited }}</text>
  92 +
  93 + <text class="section-title">Label Preview</text>
  94 +
  95 + <view class="label-card">
  96 + <view class="label-img-wrap">
  97 + <image v-if="previewImageSrc" :src="previewImageSrc" class="label-img" mode="widthFix" />
  98 + <view v-else class="label-placeholder">
  99 + <text class="label-placeholder-text">No preview image</text>
  100 + </view>
  101 + </view>
63 102 </view>
64   - <view class="info-item">
65   - <text class="info-label">Location</text>
66   - <text class="info-value">{{ locationName }}</text>
  103 +
  104 + <view class="info-row">
  105 + <view class="info-item">
  106 + <text class="info-label">Label ID</text>
  107 + <text class="info-value">{{ labelIdDisplay }}</text>
  108 + </view>
  109 + <view class="info-item">
  110 + <text class="info-label">Last Edited</text>
  111 + <text class="info-value">{{ lastEdited }}</text>
  112 + </view>
  113 + <view class="info-item">
  114 + <text class="info-label">Location</text>
  115 + <text class="info-value">{{ locationName }}</text>
  116 + </view>
67 117 </view>
68   - </view>
69 118  
70   - <view class="note-card">
71   - <AppIcon name="alert" size="sm" color="blue" />
72   - <text class="note-text">This is a preview of the label. Actual printed labels may vary slightly in appearance.</text>
73   - </view>
  119 + <view class="note-card">
  120 + <AppIcon name="alert" size="sm" color="blue" />
  121 + <text class="note-text">This is a preview of the label. Actual printed labels may vary slightly in appearance.</text>
  122 + </view>
  123 + </template>
74 124 </view>
75 125  
76 126 <view class="bottom-bar">
... ... @@ -85,187 +135,481 @@
85 135 <view class="btn-preview-sq" @click="showPreviewModal = true">
86 136 <AppIcon name="eye" size="md" color="primary" />
87 137 </view>
88   - <view class="print-btn" :class="{ disabled: isPrinting }" @click="handlePrint">
  138 + <view class="print-btn" :class="{ disabled: isPrinting || previewLoading || !systemTemplate }" @click="handlePrint">
89 139 <AppIcon name="printer" size="sm" color="white" />
90 140 <text class="print-btn-text">{{ isPrinting ? 'Printing...' : 'Print' }}</text>
91 141 </view>
92 142 </view>
93 143 </view>
94 144  
95   - <view v-if="showPreviewModal" class="modal-mask" @click="showPreviewModal = false">
  145 + <view v-if="showPreviewModal && previewImageSrc" class="modal-mask" @click="showPreviewModal = false">
96 146 <view class="modal-body modal-body-label-only" @click.stop>
97 147 <view class="modal-label-wrap">
98 148 <view class="modal-label-inner">
99   - <image :src="labelImage" class="modal-label-img" mode="widthFix" />
  149 + <image :src="previewImageSrc" class="modal-label-img" mode="widthFix" />
100 150 </view>
101 151 <view class="modal-label-id">
102 152 <text class="modal-label-id-label">Label ID</text>
103   - <text class="modal-label-id-value">{{ labelId }}</text>
  153 + <text class="modal-label-id-value">{{ labelIdDisplay }}</text>
104 154 </view>
105 155 </view>
106 156 </view>
107 157 </view>
108 158  
  159 + <canvas
  160 + canvas-id="labelPreviewCanvas"
  161 + id="labelPreviewCanvas"
  162 + class="hidden-canvas"
  163 + :style="{ width: canvasCssW + 'px', height: canvasCssH + 'px' }"
  164 + />
  165 +
  166 + <NoPrinterModal v-model="showNoPrinterModal" @connect="goBluetoothPage" />
  167 +
109 168 <SideMenu v-model="isMenuOpen" />
110 169 </view>
111 170 </template>
112 171  
113 172 <script setup lang="ts">
114   -import { ref, computed } from 'vue'
  173 +import { ref, computed, getCurrentInstance, nextTick } from 'vue'
115 174 import { onLoad, onShow } from '@dcloudio/uni-app'
116 175 import AppIcon from '../../components/AppIcon.vue'
117 176 import SideMenu from '../../components/SideMenu.vue'
118 177 import LocationPicker from '../../components/LocationPicker.vue'
  178 +import NoPrinterModal from '../../components/NoPrinterModal.vue'
119 179 import { getStatusBarHeight } from '../../utils/statusBar'
120   -import { generateNextLabelId } from '../../utils/printLog'
121   -import { getCurrentPrinterSummary, printSystemTemplateForCurrentPrinter } from '../../utils/print/manager/printerManager'
122   -import { PREVIEW_SYSTEM_TEMPLATE } from '../../utils/print/templates/previewSystemTemplate'
123   -import chickenLabelImg from '../../static/chicken-lable.png'
124   -import label1Img from '../../static/lable1.png'
125   -import label2Img from '../../static/lable2.png'
  180 +import {
  181 + getCurrentPrinterSummary,
  182 + printSystemTemplateForCurrentPrinter,
  183 +} from '../../utils/print/manager/printerManager'
  184 +import type { SystemLabelTemplate, SystemTemplateElementBase } from '../../utils/print/types/printer'
  185 +import { fetchLabelMultipleOptionById } from '../../services/labelMultipleOption'
  186 +import {
  187 + buildPrintInputJson,
  188 + printInputJsonToLabelTemplateData,
  189 + ensureFreeFieldKeys,
  190 + isPrintInputFreeFieldElement,
  191 + isPrintInputOptionsElement,
  192 + mergePrintInputFreeFields,
  193 + mergePrintOptionSelections,
  194 + readFreeFieldValuesFromTemplate,
  195 + readSelectionsFromTemplate,
  196 + validatePrintInputFreeFieldsBeforePrint,
  197 + validatePrintInputOptionsBeforePrint,
  198 +} from '../../utils/labelPreview/printInputOptions'
  199 +import { getCurrentStoreId } from '../../utils/stores'
  200 +import { postUsAppLabelPreview, postUsAppLabelPrint } from '../../services/usAppLabeling'
  201 +import {
  202 + applyTemplateProductDefaultValuesToTemplate,
  203 + extractTemplateProductDefaultValuesFromPreviewPayload,
  204 + normalizeLabelTemplateFromPreviewApi,
  205 + overlayProductNameOnPreviewTemplate,
  206 +} from '../../utils/labelPreview/normalizePreviewTemplate'
  207 +import {
  208 + getPreviewCanvasCssSize,
  209 + renderLabelPreviewToTempPath,
  210 +} from '../../utils/labelPreview/renderLabelPreviewCanvas'
  211 +import { isPrinterReadySync, checkBluetoothAdapterAvailable } from '../../utils/print/printerReadiness'
  212 +import { getBluetoothConnection, getPrinterType } from '../../utils/print/printerConnection'
  213 +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
126 214  
127 215 const statusBarHeight = getStatusBarHeight()
  216 +const instance = getCurrentInstance()?.proxy ?? null
  217 +
128 218 const isPrinting = ref(false)
129 219 const isMenuOpen = ref(false)
130 220 const printQty = ref(1)
131 221 const showPreviewModal = ref(false)
  222 +const showNoPrinterModal = ref(false)
132 223  
133 224 const btConnected = ref(false)
134 225 const btDeviceName = ref('')
135 226  
136   -const productId = ref('chicken')
137   -const productName = ref('Chicken')
138   -const productCategory = ref('Meat')
  227 +const labelCode = ref('')
  228 +const productId = ref('')
  229 +const displayProductName = ref('')
  230 +const productCategory = ref('')
139 231 const labelTypeName = ref('')
140   -const templateSize = ref('2"x2"')
141   -const templateName = ref('Basic')
142   -const lastEdited = ref('2025.12.03 11:45')
143   -const locationName = ref('Location A')
144   -const labelId = ref('')
145   -
146   -const labelImage = computed(() => {
147   - if (productId.value === 'chicken') {
148   - return chickenLabelImg
149   - }
150   - const size = templateSize.value
151   - if (size.indexOf('2"x6"') >= 0 || size.indexOf('2"x4"') >= 0) {
152   - return label2Img
153   - }
154   - return label1Img
  232 +const templateSize = ref('')
  233 +const templateName = ref('')
  234 +const lastEdited = ref('')
  235 +const locationName = ref('')
  236 +const labelIdDisplay = ref('')
  237 +
  238 +const previewLoading = ref(true)
  239 +const previewError = ref('')
  240 +const previewImageSrc = ref('')
  241 +const systemTemplate = ref<SystemLabelTemplate | null>(null)
  242 +/** 未合并「打印多选项」前的模板,用于反复 merge */
  243 +const basePreviewTemplate = ref<SystemLabelTemplate | null>(null)
  244 +const printOptionSelections = ref<Record<string, string[]>>({})
  245 +const dictLabelsByElementId = ref<Record<string, string>>({})
  246 +const dictValuesByElementId = ref<Record<string, string[]>>({})
  247 +const printTaskId = ref('')
  248 +const printFreeFieldValues = ref<Record<string, string>>({})
  249 +
  250 +let freeFieldPreviewTimer: ReturnType<typeof setTimeout> | null = null
  251 +
  252 +const printOptionFieldList = computed(() => {
  253 + const t = basePreviewTemplate.value
  254 + if (!t?.elements?.length) return []
  255 + return t.elements.filter(isPrintInputOptionsElement)
155 256 })
156 257  
157   -// 按详情规则与产品列表一致:chicken→Chicken,lable2→Cheese Burger Deluxe,lable1→Syrup
158   -const displayProductName = computed(() => {
159   - if (productId.value === 'chicken') return 'Chicken'
160   - const size = templateSize.value
161   - if (size.indexOf('2"x6"') >= 0 || size.indexOf('2"x4"') >= 0) return 'Cheese Burger Deluxe'
162   - return 'Syrup'
  258 +const printFreeFieldList = computed(() => {
  259 + const t = basePreviewTemplate.value
  260 + if (!t?.elements?.length) return []
  261 + return t.elements.filter(isPrintInputFreeFieldElement)
163 262 })
164 263  
165   -const printTemplateData = computed(() => ({
166   - product: displayProductName.value,
167   - productName: displayProductName.value,
168   - category: productCategory.value,
169   - labelId: labelId.value,
170   - qrCode: labelId.value,
171   - barcode: labelId.value,
172   -}))
  264 +function dictValuesForElement(elementId: string): string[] {
  265 + return dictValuesByElementId.value[elementId] || []
  266 +}
  267 +
  268 +function optionFieldTitle(el: SystemTemplateElementBase): string {
  269 + return (
  270 + dictLabelsByElementId.value[el.id] ||
  271 + el.elementName ||
  272 + el.inputKey ||
  273 + 'Options'
  274 + )
  275 +}
  276 +
  277 +function isOptionPicked(elementId: string, value: string): boolean {
  278 + return (printOptionSelections.value[elementId] || []).includes(value)
  279 +}
  280 +
  281 +function togglePrintOption(elementId: string, value: string) {
  282 + const cur = { ...printOptionSelections.value }
  283 + const arr = [...(cur[elementId] || [])]
  284 + const i = arr.indexOf(value)
  285 + if (i >= 0) arr.splice(i, 1)
  286 + else arr.push(value)
  287 + if (arr.length) cur[elementId] = arr
  288 + else delete cur[elementId]
  289 + printOptionSelections.value = cur
  290 + void refreshPreviewFromSelections()
  291 +}
  292 +
  293 +function freeFieldNameLabel(el: SystemTemplateElementBase): string {
  294 + const n = el.elementName || el.inputKey || 'Field'
  295 + return `${n}:`
  296 +}
  297 +
  298 +function freeFieldPlaceholder(el: SystemTemplateElementBase): string {
  299 + const c = el.config || {}
  300 + const type = String(el.type || '').toUpperCase()
  301 + if (type === 'DATE' || type === 'TIME') {
  302 + return String(c.format ?? c.Format ?? '')
  303 + }
  304 + return ''
  305 +}
  306 +
  307 +function onFreeFieldInput(elementId: string, e: { detail?: { value?: string } }) {
  308 + const v = e.detail?.value ?? ''
  309 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [elementId]: v }
  310 + if (freeFieldPreviewTimer != null) clearTimeout(freeFieldPreviewTimer)
  311 + freeFieldPreviewTimer = setTimeout(() => {
  312 + freeFieldPreviewTimer = null
  313 + void refreshPreviewFromSelections()
  314 + }, 280)
  315 +}
  316 +
  317 +function computeMergedPreviewTemplate(): SystemLabelTemplate | null {
  318 + const base = basePreviewTemplate.value
  319 + if (!base) return null
  320 + let m = mergePrintOptionSelections(
  321 + base,
  322 + printOptionSelections.value,
  323 + dictLabelsByElementId.value
  324 + )
  325 + m = mergePrintInputFreeFields(m, printFreeFieldValues.value)
  326 + return m
  327 +}
  328 +
  329 +async function loadDictionaryMetaForTemplate(t: SystemLabelTemplate) {
  330 + const labels: Record<string, string> = {}
  331 + const values: Record<string, string[]> = {}
  332 + for (const el of t.elements || []) {
  333 + if (!isPrintInputOptionsElement(el)) continue
  334 + const mid = String(el.config?.multipleOptionId ?? el.config?.MultipleOptionId ?? '').trim()
  335 + if (!mid) continue
  336 + try {
  337 + const d = await fetchLabelMultipleOptionById(mid)
  338 + if (d) {
  339 + labels[el.id] = d.optionName
  340 + values[el.id] = d.optionValuesJson.length ? d.optionValuesJson : []
  341 + }
  342 + } catch {
  343 + values[el.id] = []
  344 + }
  345 + }
  346 + dictLabelsByElementId.value = labels
  347 + dictValuesByElementId.value = values
  348 +}
  349 +
  350 +async function refreshPreviewFromSelections() {
  351 + const merged = computeMergedPreviewTemplate()
  352 + if (!merged || !instance) return
  353 + systemTemplate.value = merged
  354 + await nextTick()
  355 + try {
  356 + const path = await renderLabelPreviewToTempPath('labelPreviewCanvas', instance, merged, 720)
  357 + previewImageSrc.value = path
  358 + } catch {
  359 + /* keep previous image */
  360 + }
  361 +}
  362 +
  363 +const canvasCssW = ref(300)
  364 +const canvasCssH = ref(200)
173 365  
174 366 onShow(() => {
175 367 const summary = getCurrentPrinterSummary()
176 368 btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin'
177 369 btDeviceName.value = summary.displayName || ''
  370 + const name = uni.getStorageSync('storeName')
  371 + if (typeof name === 'string' && name.trim()) locationName.value = name.trim()
178 372 })
179 373  
180   -interface ProductData {
181   - name: string
182   - category: string
183   - templateSize: string
184   - templateName: string
185   - lastEdited: string
186   - labelTypes: { id: string; name: string }[]
187   -}
188   -
189   -const productMap: Record<string, ProductData> = {
190   - 'chicken': { name: 'Chicken', category: 'Meat', templateSize: '2"x2"', templateName: 'Basic', lastEdited: '2025.12.03 11:45', labelTypes: [{ id: 'defrost', name: 'Defrost' }, { id: 'opened', name: 'Opened/Preped' }, { id: 'heated', name: 'Heated' }] },
191   - 'beef': { name: 'Beef', category: 'Meat', templateSize: '2"x2"', templateName: 'Basic', lastEdited: '2025.12.04 09:20', labelTypes: [{ id: 'defrost', name: 'Defrost' }, { id: 'opened', name: 'Opened/Preped' }, { id: 'heated', name: 'Heated' }] },
192   - 'bacon': { name: 'Bacon', category: 'Meat', templateSize: '2"x2"', templateName: 'Basic', lastEdited: '2025.12.03 14:30', labelTypes: [{ id: 'raw', name: 'Raw Bacon' }, { id: 'cooked', name: 'Cooked Bacon' }] },
193   - 'chicken-sandwich': { name: 'Chicken Sandwich', category: 'Sandwich', templateSize: '2"x6"', templateName: "G'n'G", lastEdited: '2025.12.03 11:45', labelTypes: [] },
194   - 'turkey-club': { name: 'Turkey Club', category: 'Sandwich', templateSize: '2"x6"', templateName: "G'n'G", lastEdited: '2025.12.04 09:30', labelTypes: [] },
195   - 'cheese-burger': { name: 'Cheese Burger', category: 'Sandwich', templateSize: '2"x6"', templateName: "G'n'G", lastEdited: '2025.12.03 15:20', labelTypes: [] },
196   - 'caesar-salad': { name: 'Caesar Salad', category: 'Salads', templateSize: '2"x4"', templateName: "G'n'G", lastEdited: '2025.12.04 09:00', labelTypes: [] },
197   - 'greek-salad': { name: 'Greek Salad', category: 'Salads', templateSize: '2"x4"', templateName: "G'n'G", lastEdited: '2025.12.03 12:30', labelTypes: [] },
198   - 'fresh-juice': { name: 'Fresh Juice', category: 'Beverages', templateSize: '2"x2"', templateName: "G'n'G", lastEdited: '2025.12.04 07:45', labelTypes: [] },
199   - 'smoothie': { name: 'Smoothie', category: 'Beverages', templateSize: '2"x2"', templateName: "G'n'G", lastEdited: '2025.12.03 11:00', labelTypes: [] },
200   - 'milk': { name: 'Milk', category: 'Dairy', templateSize: '2"x2"', templateName: 'Basic', lastEdited: '2025.12.04 08:30', labelTypes: [] },
201   - 'cheese': { name: 'Cheese', category: 'Dairy', templateSize: '2"x2"', templateName: 'Basic', lastEdited: '2025.12.03 11:15', labelTypes: [] },
202   - 'sliced-ham': { name: 'Sliced Ham', category: 'Deli', templateSize: '2"x2"', templateName: 'Basic', lastEdited: '2025.12.04 10:00', labelTypes: [] },
203   - 'turkey-deli': { name: 'Turkey Deli', category: 'Deli', templateSize: '2"x2"', templateName: 'Basic', lastEdited: '2025.12.03 14:20', labelTypes: [] },
204   -}
205   -
206   -onLoad((opts: any) => {
207   - const pid = (opts && opts.productId) || 'chicken'
208   - const subType = (opts && opts.subType) || ''
209   -
210   - const product = productMap[pid]
211   - if (product) {
212   - productId.value = pid
213   - productName.value = product.name
214   - productCategory.value = product.category
215   - templateSize.value = product.templateSize
216   - templateName.value = product.templateName
217   - lastEdited.value = product.lastEdited
218   -
219   - if (subType) {
220   - const found = product.labelTypes.filter(function (lt) { return lt.id === subType })
221   - if (found.length > 0) {
222   - labelTypeName.value = found[0].name
223   - }
224   - }
  374 +onLoad((opts: Record<string, string | undefined>) => {
  375 + labelCode.value = decodeURIComponent(opts.labelCode || '')
  376 + productId.value = decodeURIComponent(opts.productId || '')
  377 + displayProductName.value = decodeURIComponent(opts.productName || '')
  378 + productCategory.value = decodeURIComponent(opts.categoryName || '')
  379 + labelTypeName.value = decodeURIComponent(opts.typeName || '')
  380 + templateSize.value = decodeURIComponent(opts.templateSize || '')
  381 + templateName.value = decodeURIComponent(opts.templateName || '')
  382 +
  383 + const name = uni.getStorageSync('storeName')
  384 + if (typeof name === 'string' && name.trim()) locationName.value = name.trim()
  385 + labelIdDisplay.value = '—'
  386 + lastEdited.value = '—'
  387 +
  388 + loadPreview()
  389 +})
  390 +
  391 +async function loadPreview() {
  392 + const loc = getCurrentStoreId()
  393 + if (!loc) {
  394 + previewError.value = 'No store selected.'
  395 + previewLoading.value = false
  396 + return
  397 + }
  398 + if (!labelCode.value) {
  399 + previewError.value = 'Missing label code.'
  400 + previewLoading.value = false
  401 + return
225 402 }
226 403  
227   - const storeId = uni.getStorageSync('storeId') || '001'
228   - locationName.value = 'Location A'
229   - labelId.value = generateNextLabelId()
230   -})
  404 + previewLoading.value = true
  405 + previewError.value = ''
  406 + previewImageSrc.value = ''
  407 + systemTemplate.value = null
  408 + basePreviewTemplate.value = null
  409 + printOptionSelections.value = {}
  410 + printFreeFieldValues.value = {}
  411 + dictLabelsByElementId.value = {}
  412 + dictValuesByElementId.value = {}
  413 +
  414 + try {
  415 + const raw = await postUsAppLabelPreview({
  416 + locationId: loc,
  417 + labelCode: labelCode.value,
  418 + productId: productId.value || undefined,
  419 + })
  420 + const root = raw as Record<string, unknown>
  421 + const nested = root.data ?? root.Data
  422 + const inner =
  423 + nested != null && typeof nested === 'object' && !Array.isArray(nested)
  424 + ? (nested as Record<string, unknown>)
  425 + : null
  426 +
  427 + /** 8.2 预览:labelLastEdited / labelId(兼容 data 嵌套、lastEdited、PascalCase) */
  428 + const readLastEdited = (L: Record<string, unknown> | null): string => {
  429 + if (!L) return ''
  430 + const v =
  431 + L.labelLastEdited ??
  432 + L.LabelLastEdited ??
  433 + L.lastEdited ??
  434 + L.LastEdited
  435 + return typeof v === 'string' && v.trim() ? v.trim() : ''
  436 + }
  437 + const readLabelId = (L: Record<string, unknown> | null): string => {
  438 + if (!L) return ''
  439 + const v = L.labelId ?? L.LabelId
  440 + return v != null && String(v).trim() !== '' ? String(v).trim() : ''
  441 + }
231 442  
232   -const increment = () => { if (printQty.value < 99) printQty.value++ }
233   -const decrement = () => { if (printQty.value > 1) printQty.value-- }
  443 + const leStr = readLastEdited(root) || readLastEdited(inner)
  444 + lastEdited.value = leStr || '—'
  445 +
  446 + const lidStr = readLabelId(root) || readLabelId(inner)
  447 + if (lidStr) labelIdDisplay.value = lidStr
  448 +
  449 + const tmplPayload =
  450 + inner != null &&
  451 + (inner.template != null ||
  452 + inner.Template != null ||
  453 + Array.isArray(inner.elements) ||
  454 + Array.isArray(inner.Elements))
  455 + ? inner
  456 + : raw
  457 + const tmplRaw = normalizeLabelTemplateFromPreviewApi(tmplPayload)
  458 + if (!tmplRaw) {
  459 + previewError.value = 'Invalid preview template.'
  460 + previewLoading.value = false
  461 + return
  462 + }
  463 + const lstRaw =
  464 + root.labelSizeText ??
  465 + root.LabelSizeText ??
  466 + inner?.labelSizeText ??
  467 + inner?.LabelSizeText
  468 + const labelSizeTextFromApi = typeof lstRaw === 'string' ? lstRaw : null
  469 + if (labelSizeTextFromApi && labelSizeTextFromApi.trim()) {
  470 + templateSize.value = labelSizeTextFromApi.trim()
  471 + }
  472 + const productDefaults = extractTemplateProductDefaultValuesFromPreviewPayload(raw)
  473 + const tmplWithDefaults =
  474 + Object.keys(productDefaults).length > 0
  475 + ? applyTemplateProductDefaultValuesToTemplate(tmplRaw, productDefaults)
  476 + : tmplRaw
  477 + /** 画布像素仅按接口 template 的 width / height / unit 换算(与 renderLabelPreviewCanvas.toCanvasPx 一致),不用 labelSizeText 覆盖以免单位被误判 */
  478 + const base = overlayProductNameOnPreviewTemplate(tmplWithDefaults, displayProductName.value)
  479 + basePreviewTemplate.value = base
  480 + printOptionSelections.value = readSelectionsFromTemplate(base)
  481 + printFreeFieldValues.value = ensureFreeFieldKeys(base, readFreeFieldValuesFromTemplate(base))
  482 + await loadDictionaryMetaForTemplate(base)
  483 + const merged = computeMergedPreviewTemplate()
  484 + if (!merged) {
  485 + previewError.value = 'Invalid preview template.'
  486 + previewLoading.value = false
  487 + return
  488 + }
  489 + systemTemplate.value = merged
  490 +
  491 + const sz = getPreviewCanvasCssSize(merged, 720)
  492 + canvasCssW.value = sz.width
  493 + canvasCssH.value = sz.height
  494 +
  495 + await nextTick()
  496 + if (!instance) {
  497 + previewError.value = 'Preview unavailable.'
  498 + previewLoading.value = false
  499 + return
  500 + }
  501 + const path = await renderLabelPreviewToTempPath('labelPreviewCanvas', instance, merged, 720)
  502 + previewImageSrc.value = path
  503 + } catch (e: unknown) {
  504 + if (isUsAppSessionExpiredError(e)) return
  505 + previewError.value = e instanceof Error ? e.message : 'Preview failed.'
  506 + } finally {
  507 + previewLoading.value = false
  508 + }
  509 +}
  510 +
  511 +const increment = () => {
  512 + if (printQty.value < 99) printQty.value++
  513 +}
  514 +const decrement = () => {
  515 + if (printQty.value > 1) printQty.value--
  516 +}
234 517 const goBack = () => uni.navigateBack()
235 518  
  519 +const goBluetoothPage = () => {
  520 + uni.navigateTo({ url: '/pages/labels/bluetooth' })
  521 +}
  522 +
236 523 const handlePrint = async () => {
237   - if (isPrinting.value) return
238   - if (!btConnected.value) {
239   - uni.showModal({
240   - title: 'No Printer Connected',
241   - content: 'Please connect a Bluetooth or built-in printer first.',
242   - confirmText: 'Connect',
243   - cancelText: 'Cancel',
244   - success: (res) => {
245   - if (res.confirm) uni.navigateTo({ url: '/pages/labels/bluetooth' })
246   - },
247   - })
  524 + if (isPrinting.value || previewLoading.value || !systemTemplate.value) return
  525 +
  526 + if (!isPrinterReadySync()) {
  527 + showNoPrinterModal.value = true
  528 + return
  529 + }
  530 +
  531 + if (getPrinterType() === 'bluetooth') {
  532 + const adapterOk = await checkBluetoothAdapterAvailable()
  533 + if (!adapterOk) {
  534 + showNoPrinterModal.value = true
  535 + return
  536 + }
  537 + }
  538 +
  539 + const loc = getCurrentStoreId()
  540 + if (!loc) {
  541 + uni.showToast({ title: 'No store selected.', icon: 'none' })
248 542 return
249 543 }
  544 +
  545 + const tmplForValidate = basePreviewTemplate.value ?? systemTemplate.value
  546 + if (tmplForValidate) {
  547 + const optErr = validatePrintInputOptionsBeforePrint(
  548 + tmplForValidate,
  549 + printOptionSelections.value,
  550 + dictLabelsByElementId.value
  551 + )
  552 + if (optErr) {
  553 + uni.showToast({ title: optErr, icon: 'none', duration: 3000 })
  554 + return
  555 + }
  556 + const freeErr = validatePrintInputFreeFieldsBeforePrint(
  557 + tmplForValidate,
  558 + printFreeFieldValues.value
  559 + )
  560 + if (freeErr) {
  561 + uni.showToast({ title: freeErr, icon: 'none', duration: 3000 })
  562 + return
  563 + }
  564 + }
  565 +
250 566 isPrinting.value = true
251 567 try {
252   - uni.showLoading({ title: 'Sending print job...', mask: true })
253   - await new Promise(resolve => setTimeout(resolve, 30))
254   - await printSystemTemplateForCurrentPrinter(PREVIEW_SYSTEM_TEMPLATE, printTemplateData.value, {
255   - printQty: printQty.value,
  568 + uni.showLoading({ title: 'Submitting print…', mask: true })
  569 + const bt = getBluetoothConnection()
  570 + /** 与当前画布一致:用合并后的模板(多选项 + 自由输入 + 平台默认值)组装 printInputJson,key 与控件 inputKey/elementName 对齐 */
  571 + const mergedForPrint = computeMergedPreviewTemplate()
  572 + const tmplForJson =
  573 + mergedForPrint ?? basePreviewTemplate.value ?? systemTemplate.value ?? null
  574 + const pj =
  575 + tmplForJson != null
  576 + ? buildPrintInputJson(
  577 + tmplForJson,
  578 + printOptionSelections.value,
  579 + printFreeFieldValues.value
  580 + )
  581 + : {}
  582 + const templateDataForPrinter = printInputJsonToLabelTemplateData(pj)
  583 + const out = await postUsAppLabelPrint({
  584 + locationId: loc,
  585 + labelCode: labelCode.value,
  586 + productId: productId.value || undefined,
  587 + printQuantity: printQty.value,
  588 + printerMac: bt?.deviceId || undefined,
  589 + printerAddress: bt?.deviceId || undefined,
  590 + printInputJson: Object.keys(pj).length > 0 ? pj : undefined,
256 591 })
  592 + printTaskId.value = out.taskId || ''
  593 + if (printTaskId.value) labelIdDisplay.value = printTaskId.value
  594 +
  595 + await printSystemTemplateForCurrentPrinter(
  596 + mergedForPrint ?? systemTemplate.value,
  597 + templateDataForPrinter,
  598 + { printQty: printQty.value }
  599 + )
257 600 uni.hideLoading()
258 601 uni.showToast({
259   - title: printQty.value + ' label' + (printQty.value > 1 ? 's' : '') + ' printed!',
  602 + title: `${printQty.value} label${printQty.value > 1 ? 's' : ''} printed!`,
260 603 icon: 'success',
261 604 })
262 605 } catch (e: any) {
263 606 uni.hideLoading()
264   - const msg = (e && e.message) ? e.message : 'Print failed'
  607 + const msg = e?.message ? String(e.message) : 'Print failed'
265 608 if (msg === 'BUILTIN_PLUGIN_NOT_FOUND' || (msg && msg.indexOf('Built-in printer') !== -1)) {
266 609 uni.showModal({
267 610 title: 'Built-in Print Not Available',
268   - content: 'This device does not support TCP built-in printing. Please switch to Bluetooth mode: go to Printer Settings, tap Scan, and connect to your printer (look for a device name like "Virtual BT Printer" or your printer model). You may need to pair it first in Android Bluetooth settings.',
  611 + content:
  612 + 'This device does not support TCP built-in printing. Please use Bluetooth: Printer Settings → connect your printer.',
269 613 confirmText: 'Printer Settings',
270 614 cancelText: 'Cancel',
271 615 success: (res) => {
... ... @@ -273,11 +617,7 @@ const handlePrint = async () =&gt; {
273 617 },
274 618 })
275 619 } else {
276   - uni.showToast({
277   - title: msg,
278   - icon: 'none',
279   - duration: 3000,
280   - })
  620 + uni.showToast({ title: msg, icon: 'none', duration: 3000 })
281 621 }
282 622 } finally {
283 623 isPrinting.value = false
... ... @@ -344,6 +684,16 @@ const handlePrint = async () =&gt; {
344 684 min-width: 0;
345 685 }
346 686  
  687 +.state-block {
  688 + padding: 80rpx 24rpx;
  689 + text-align: center;
  690 +}
  691 +
  692 +.state-text {
  693 + font-size: 28rpx;
  694 + color: #6b7280;
  695 +}
  696 +
347 697 .food-card {
348 698 background: #fff;
349 699 padding: 28rpx;
... ... @@ -458,6 +808,86 @@ const handlePrint = async () =&gt; {
458 808 color: #111827;
459 809 }
460 810  
  811 +.print-options-card {
  812 + background: #fff;
  813 + border-radius: 20rpx;
  814 + padding: 28rpx;
  815 + margin-bottom: 28rpx;
  816 + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
  817 +}
  818 +
  819 +.print-options-title {
  820 + font-size: 30rpx;
  821 + font-weight: 600;
  822 + color: #111827;
  823 + display: block;
  824 + margin-bottom: 12rpx;
  825 +}
  826 +
  827 +.print-options-hint {
  828 + font-size: 24rpx;
  829 + color: #6b7280;
  830 + line-height: 1.45;
  831 + display: block;
  832 + margin-bottom: 24rpx;
  833 +}
  834 +
  835 +.print-option-block {
  836 + margin-bottom: 28rpx;
  837 +}
  838 +
  839 +.print-option-block:last-child {
  840 + margin-bottom: 0;
  841 +}
  842 +
  843 +.print-option-label {
  844 + font-size: 26rpx;
  845 + font-weight: 600;
  846 + color: #374151;
  847 + display: block;
  848 + margin-bottom: 16rpx;
  849 +}
  850 +
  851 +.chip-row {
  852 + display: flex;
  853 + flex-wrap: wrap;
  854 + gap: 16rpx;
  855 +}
  856 +
  857 +.chip {
  858 + padding: 12rpx 24rpx;
  859 + border-radius: 999rpx;
  860 + background: #f3f4f6;
  861 + border: 2rpx solid #e5e7eb;
  862 +}
  863 +
  864 +.chip-on {
  865 + background: #eff6ff;
  866 + border-color: var(--theme-primary);
  867 +}
  868 +
  869 +.chip-text {
  870 + font-size: 24rpx;
  871 + color: #374151;
  872 +}
  873 +
  874 +.chip-on .chip-text {
  875 + color: var(--theme-primary);
  876 + font-weight: 600;
  877 +}
  878 +
  879 +.free-field-input {
  880 + width: 100%;
  881 + box-sizing: border-box;
  882 + height: 80rpx;
  883 + padding: 0 24rpx;
  884 + font-size: 28rpx;
  885 + color: #111827;
  886 + background: #f9fafb;
  887 + border: 2rpx solid #e5e7eb;
  888 + border-radius: 12rpx;
  889 +}
  890 +
461 891 .section-title {
462 892 font-size: 30rpx;
463 893 font-weight: 600;
... ... @@ -466,7 +896,6 @@ const handlePrint = async () =&gt; {
466 896 margin-bottom: 20rpx;
467 897 }
468 898  
469   -/* 使用 block 布局避免移动端 Safari flex+图片溢出问题 */
470 899 .label-card {
471 900 background: #fff;
472 901 border-radius: 20rpx;
... ... @@ -491,7 +920,6 @@ const handlePrint = async () =&gt; {
491 920 position: relative;
492 921 }
493 922  
494   -/* 移动端:width:auto + max-width:100% 比 width:100% 更可靠 */
495 923 .label-img {
496 924 width: 100%;
497 925 max-width: 100%;
... ... @@ -503,6 +931,18 @@ const handlePrint = async () =&gt; {
503 931 box-sizing: border-box;
504 932 }
505 933  
  934 +.label-placeholder {
  935 + padding: 80rpx 24rpx;
  936 + text-align: center;
  937 + background: #f3f4f6;
  938 + border-radius: 12rpx;
  939 +}
  940 +
  941 +.label-placeholder-text {
  942 + font-size: 26rpx;
  943 + color: #9ca3af;
  944 +}
  945 +
506 946 .info-row {
507 947 display: flex;
508 948 margin-bottom: 24rpx;
... ... @@ -558,7 +998,6 @@ const handlePrint = async () =&gt; {
558 998 left: 0;
559 999 right: 0;
560 1000 padding: 20rpx 32rpx;
561   - /* 兼容浏览器:env(safe-area-inset-bottom) + 最小 36px,避免打印按钮偏上/遮挡 */
562 1001 padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 36px);
563 1002 background: #fff;
564 1003 border-top: 1rpx solid #e5e7eb;
... ... @@ -649,6 +1088,14 @@ const handlePrint = async () =&gt; {
649 1088 line-height: 1;
650 1089 }
651 1090  
  1091 +.hidden-canvas {
  1092 + position: fixed;
  1093 + left: -2000px;
  1094 + top: 0;
  1095 + width: 300px;
  1096 + height: 200px;
  1097 +}
  1098 +
652 1099 .modal-mask {
653 1100 position: fixed;
654 1101 top: 0;
... ... @@ -703,42 +1150,6 @@ const handlePrint = async () =&gt; {
703 1150 font-weight: 600;
704 1151 }
705 1152  
706   -.modal-top {
707   - display: flex;
708   - justify-content: space-between;
709   - align-items: center;
710   - padding: 28rpx 32rpx;
711   - border-bottom: 1rpx solid #e5e7eb;
712   - flex-shrink: 0;
713   -}
714   -
715   -.modal-title {
716   - font-size: 30rpx;
717   - font-weight: 600;
718   - color: #111827;
719   -}
720   -
721   -.modal-close {
722   - width: 52rpx;
723   - height: 52rpx;
724   - display: flex;
725   - align-items: center;
726   - justify-content: center;
727   - border-radius: 50%;
728   - background: #f3f4f6;
729   - transform: rotate(45deg);
730   -}
731   -
732   -.modal-scroll {
733   - flex: 1;
734   - min-width: 0;
735   - padding: 28rpx 40rpx;
736   - overflow-y: auto;
737   - overflow-x: hidden;
738   - overflow-x: clip;
739   - box-sizing: border-box;
740   -}
741   -
742 1153 .modal-label-wrap {
743 1154 width: 100%;
744 1155 max-width: 100%;
... ... @@ -769,26 +1180,6 @@ const handlePrint = async () =&gt; {
769 1180 box-sizing: border-box;
770 1181 }
771 1182  
772   -.modal-info {
773   - text-align: center;
774   -}
775   -
776   -.modal-info-text {
777   - font-size: 30rpx;
778   - font-weight: 600;
779   - color: #111827;
780   - display: block;
781   - margin-bottom: 8rpx;
782   -}
783   -
784   -.modal-info-sub {
785   - font-size: 24rpx;
786   - color: #6b7280;
787   - display: block;
788   - margin-bottom: 4rpx;
789   -}
790   -
791   -/* 移动端额外约束,防止部分浏览器仍出现溢出 */
792 1183 @media screen and (max-width: 768px) {
793 1184 .page,
794 1185 .content,
... ...
美国版/Food Labeling Management App UniApp/src/pages/login/login.vue
... ... @@ -64,8 +64,8 @@ import {
64 64  
65 65 const { t } = useI18n()
66 66 const statusBarHeight = getStatusBarHeight()
67   -const email = ref('')
68   -const password = ref('')
  67 +const email = ref('21615123@q65w4')
  68 +const password = ref('123456')
69 69 const rememberMe = ref(false)
70 70 const isLoading = ref(false)
71 71  
... ...
美国版/Food Labeling Management App UniApp/src/services/labelCategory.ts 0 → 100644
  1 +import type { LabelCategoryListItemDto } from '../types/platformCategories'
  2 +import { usAppApiRequest } from '../utils/usAppApiRequest'
  3 +import { extractPagedItems } from '../utils/pagedList'
  4 +
  5 +export type LabelCategoryListQuery = {
  6 + skipCount?: number
  7 + maxResultCount?: number
  8 + sorting?: string
  9 + keyword?: string
  10 + state?: boolean
  11 +}
  12 +
  13 +function buildQuery(q: LabelCategoryListQuery): string {
  14 + const p = new URLSearchParams()
  15 + /** 第一页为 1(页码语义,非 offset) */
  16 + p.set('skipCount', String(q.skipCount ?? 1))
  17 + p.set('maxResultCount', String(q.maxResultCount ?? 50))
  18 + if (q.sorting != null && String(q.sorting).trim() !== '') {
  19 + p.set('sorting', String(q.sorting).trim())
  20 + }
  21 + if (q.keyword != null && String(q.keyword).trim() !== '') {
  22 + p.set('keyword', String(q.keyword).trim())
  23 + }
  24 + if (q.state === true) {
  25 + p.set('state', 'true')
  26 + }
  27 + return p.toString()
  28 +}
  29 +
  30 +/** GET /api/app/label-category — 《标签模块接口对接说明(6).md》接口 1.1 */
  31 +export async function fetchLabelCategoryPage(
  32 + query: LabelCategoryListQuery = {}
  33 +): Promise<{ items: LabelCategoryListItemDto[]; totalCount: number }> {
  34 + const qs = buildQuery(query)
  35 + const body = await usAppApiRequest<unknown>({
  36 + path: `/api/app/label-category?${qs}`,
  37 + method: 'GET',
  38 + auth: true,
  39 + })
  40 + const { items, totalCount } = extractPagedItems<LabelCategoryListItemDto>(body)
  41 + return { items, totalCount }
  42 +}
... ...
美国版/Food Labeling Management App UniApp/src/services/labelMultipleOption.ts 0 → 100644
  1 +import { unwrapApiPayload, usAppApiRequest } from '../utils/usAppApiRequest'
  2 +
  3 +function parseOptionValuesJson(raw: unknown): string[] {
  4 + if (raw == null) return []
  5 + if (Array.isArray(raw)) return raw.map((x) => String(x))
  6 + if (typeof raw === 'string') {
  7 + const t = raw.trim()
  8 + if (!t) return []
  9 + try {
  10 + const p = JSON.parse(t) as unknown
  11 + if (Array.isArray(p)) return p.map((x) => String(x))
  12 + } catch {
  13 + return []
  14 + }
  15 + }
  16 + return []
  17 +}
  18 +
  19 +export type LabelMultipleOptionDetail = {
  20 + id: string
  21 + optionName: string
  22 + optionCode: string
  23 + optionValuesJson: string[]
  24 +}
  25 +
  26 +/**
  27 + * GET /api/app/label-multiple-option/{id}(与 Web 管理端一致)
  28 + */
  29 +export async function fetchLabelMultipleOptionById(id: string): Promise<LabelMultipleOptionDetail | null> {
  30 + const tid = String(id ?? '').trim()
  31 + if (!tid) return null
  32 + const raw = await usAppApiRequest<unknown>({
  33 + path: `/api/app/label-multiple-option/${encodeURIComponent(tid)}`,
  34 + method: 'GET',
  35 + auth: true,
  36 + })
  37 + const d = unwrapApiPayload(raw) as Record<string, unknown>
  38 + if (!d || typeof d !== 'object') return null
  39 + const oid = String(d.id ?? d.Id ?? tid)
  40 + const optionValuesJson = parseOptionValuesJson(d.optionValuesJson ?? d.OptionValuesJson)
  41 + return {
  42 + id: oid,
  43 + optionName: String(d.optionName ?? d.OptionName ?? '').trim() || oid,
  44 + optionCode: String(d.optionCode ?? d.OptionCode ?? '').trim(),
  45 + optionValuesJson,
  46 + }
  47 +}
... ...
美国版/Food Labeling Management App UniApp/src/services/productCategory.ts 0 → 100644
  1 +import type { ProductCategoryListItemDto } from '../types/platformCategories'
  2 +import { usAppApiRequest } from '../utils/usAppApiRequest'
  3 +import { extractPagedItems } from '../utils/pagedList'
  4 +
  5 +export type ProductCategoryListQuery = {
  6 + skipCount?: number
  7 + maxResultCount?: number
  8 + sorting?: string
  9 + keyword?: string
  10 + state?: boolean
  11 +}
  12 +
  13 +function buildQuery(q: ProductCategoryListQuery): string {
  14 + const p = new URLSearchParams()
  15 + /** 第一页为 1(页码语义,非 offset) */
  16 + p.set('skipCount', String(q.skipCount ?? 1))
  17 + p.set('maxResultCount', String(q.maxResultCount ?? 50))
  18 + if (q.sorting != null && String(q.sorting).trim() !== '') {
  19 + p.set('sorting', String(q.sorting).trim())
  20 + }
  21 + if (q.keyword != null && String(q.keyword).trim() !== '') {
  22 + p.set('keyword', String(q.keyword).trim())
  23 + }
  24 + if (q.state === true) {
  25 + p.set('state', 'true')
  26 + }
  27 + return p.toString()
  28 +}
  29 +
  30 +/** GET /api/app/product-category — 《产品模块Categories接口对接说明(1).md》接口 1 */
  31 +export async function fetchProductCategoryPage(
  32 + query: ProductCategoryListQuery = {}
  33 +): Promise<{ items: ProductCategoryListItemDto[]; totalCount: number }> {
  34 + const qs = buildQuery(query)
  35 + const body = await usAppApiRequest<unknown>({
  36 + path: `/api/app/product-category?${qs}`,
  37 + method: 'GET',
  38 + auth: true,
  39 + })
  40 + const { items, totalCount } = extractPagedItems<ProductCategoryListItemDto>(body)
  41 + return { items, totalCount }
  42 +}
... ...
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts 0 → 100644
  1 +import type {
  2 + UsAppLabelCategoryTreeNodeDto,
  3 + UsAppLabelingProductNodeDto,
  4 + UsAppLabelPreviewInputVo,
  5 + UsAppLabelPrintInputVo,
  6 + UsAppLabelPrintOutputDto,
  7 + UsAppLabelTypeNodeDto,
  8 + UsAppProductCategoryNodeDto,
  9 +} from '../types/usAppLabeling'
  10 +import { usAppApiRequest } from '../utils/usAppApiRequest'
  11 +
  12 +function asArr(v: unknown): unknown[] {
  13 + return Array.isArray(v) ? v : []
  14 +}
  15 +
  16 +/** 兼容 camelCase / PascalCase,对齐《标签模块接口对接说明(6).md》8.1 四级树 */
  17 +function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeDto[] {
  18 + const list = asArr(raw)
  19 + return list.map((node: any) => {
  20 + const pcs = asArr(node?.productCategories ?? node?.ProductCategories)
  21 + const productCategories: UsAppProductCategoryNodeDto[] = pcs.map((p: any) => {
  22 + const prods = asArr(p?.products ?? p?.Products)
  23 + const products: UsAppLabelingProductNodeDto[] = prods.map((x: any) => {
  24 + const lts = asArr(x?.labelTypes ?? x?.LabelTypes)
  25 + const labelTypes: UsAppLabelTypeNodeDto[] = lts.map((t: any) => ({
  26 + labelTypeId: String(t?.labelTypeId ?? t?.LabelTypeId ?? ''),
  27 + typeName: String(t?.typeName ?? t?.TypeName ?? ''),
  28 + orderNum: Number(t?.orderNum ?? t?.OrderNum ?? 0),
  29 + labelCode: String(t?.labelCode ?? t?.LabelCode ?? ''),
  30 + templateCode: (t?.templateCode ?? t?.TemplateCode ?? null) as string | null,
  31 + labelSizeText: (t?.labelSizeText ?? t?.LabelSizeText ?? null) as string | null,
  32 + }))
  33 + return {
  34 + productId: String(x?.productId ?? x?.ProductId ?? ''),
  35 + productName: String(x?.productName ?? x?.ProductName ?? ''),
  36 + productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
  37 + productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null,
  38 + subtitle: String(x?.subtitle ?? x?.Subtitle ?? ''),
  39 + labelTypeCount: Number(x?.labelTypeCount ?? x?.LabelTypeCount ?? labelTypes.length),
  40 + labelTypes,
  41 + }
  42 + })
  43 + const catId = p?.categoryId ?? p?.CategoryId
  44 + return {
  45 + categoryId: catId != null && String(catId).trim() !== '' ? String(catId) : null,
  46 + categoryPhotoUrl: (p?.categoryPhotoUrl ?? p?.CategoryPhotoUrl ?? null) as string | null,
  47 + name: String(p?.name ?? p?.Name ?? ''),
  48 + itemCount: Number(p?.itemCount ?? p?.ItemCount ?? products.length),
  49 + products,
  50 + }
  51 + })
  52 + return {
  53 + id: String(node?.id ?? node?.Id ?? ''),
  54 + categoryName: String(node?.categoryName ?? node?.CategoryName ?? ''),
  55 + categoryPhotoUrl: (node?.categoryPhotoUrl ?? node?.CategoryPhotoUrl ?? null) as string | null,
  56 + orderNum: Number(node?.orderNum ?? node?.OrderNum ?? 0),
  57 + productCategories,
  58 + }
  59 + })
  60 +}
  61 +
  62 +function buildLabelingTreePath(params: {
  63 + locationId: string
  64 + keyword?: string
  65 + labelCategoryId?: string
  66 +}): string {
  67 + const q: string[] = [`locationId=${encodeURIComponent(params.locationId)}`]
  68 + if (params.keyword != null && String(params.keyword).trim() !== '') {
  69 + q.push(`keyword=${encodeURIComponent(String(params.keyword).trim())}`)
  70 + }
  71 + if (params.labelCategoryId != null && String(params.labelCategoryId).trim() !== '') {
  72 + q.push(`labelCategoryId=${encodeURIComponent(String(params.labelCategoryId).trim())}`)
  73 + }
  74 + return `/api/app/us-app-labeling/labeling-tree?${q.join('&')}`
  75 +}
  76 +
  77 +/** 接口 8.1 */
  78 +export async function fetchUsAppLabelingTree(input: {
  79 + locationId: string
  80 + keyword?: string
  81 + labelCategoryId?: string
  82 +}): Promise<UsAppLabelCategoryTreeNodeDto[]> {
  83 + const raw = await usAppApiRequest<unknown>({
  84 + path: buildLabelingTreePath(input),
  85 + method: 'GET',
  86 + auth: true,
  87 + })
  88 + return normalizeLabelingTreePayload(raw)
  89 +}
  90 +
  91 +/** 接口 8.2 */
  92 +export async function postUsAppLabelPreview(body: UsAppLabelPreviewInputVo): Promise<unknown> {
  93 + return usAppApiRequest<unknown>({
  94 + path: '/api/app/us-app-labeling/preview',
  95 + method: 'POST',
  96 + auth: true,
  97 + data: body,
  98 + })
  99 +}
  100 +
  101 +/** 接口 9.1 */
  102 +export async function postUsAppLabelPrint(body: UsAppLabelPrintInputVo): Promise<UsAppLabelPrintOutputDto> {
  103 + return usAppApiRequest<UsAppLabelPrintOutputDto>({
  104 + path: '/api/app/us-app-labeling/print',
  105 + method: 'POST',
  106 + auth: true,
  107 + data: body,
  108 + })
  109 +}
... ...
美国版/Food Labeling Management App UniApp/src/types/platformCategories.ts 0 → 100644
  1 +/** 《产品模块Categories接口对接说明(1).md》列表项 */
  2 +export interface ProductCategoryListItemDto {
  3 + id: string
  4 + categoryCode: string
  5 + categoryName: string
  6 + categoryPhotoUrl: string | null
  7 + state: boolean
  8 + orderNum: number
  9 + lastEdited?: string | null
  10 +}
  11 +
  12 +/** 《标签模块接口对接说明(6).md》接口 1.1 列表项(字段名与产品分类对齐) */
  13 +export interface LabelCategoryListItemDto {
  14 + id: string
  15 + categoryCode: string
  16 + categoryName: string
  17 + categoryPhotoUrl: string | null
  18 + state: boolean
  19 + orderNum: number
  20 + lastEdited?: string | null
  21 +}
  22 +
  23 +export interface PagedListResultDto<T> {
  24 + pageIndex: number
  25 + pageSize: number
  26 + totalCount: number
  27 + totalPages: number
  28 + items: T[]
  29 +}
... ...
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts 0 → 100644
  1 +/** 对齐《标签模块接口对接说明(6).md》接口 8 / 8.2 / 9 */
  2 +
  3 +export interface UsAppLabelTypeNodeDto {
  4 + labelTypeId: string
  5 + typeName: string
  6 + orderNum: number
  7 + labelCode: string
  8 + templateCode: string | null
  9 + labelSizeText: string | null
  10 +}
  11 +
  12 +export interface UsAppLabelingProductNodeDto {
  13 + productId: string
  14 + productName: string
  15 + productCode: string
  16 + productImageUrl: string | null
  17 + subtitle: string
  18 + labelTypeCount: number
  19 + labelTypes: UsAppLabelTypeNodeDto[]
  20 +}
  21 +
  22 +export interface UsAppProductCategoryNodeDto {
  23 + /** L2:`fl_product_category.Id`,未归类时可为空 */
  24 + categoryId?: string | null
  25 + /** 产品分类图,与《标签模块接口对接说明(6).md》8.1 一致 */
  26 + categoryPhotoUrl?: string | null
  27 + name: string
  28 + itemCount: number
  29 + products: UsAppLabelingProductNodeDto[]
  30 +}
  31 +
  32 +export interface UsAppLabelCategoryTreeNodeDto {
  33 + id: string
  34 + categoryName: string
  35 + categoryPhotoUrl: string | null
  36 + orderNum: number
  37 + productCategories: UsAppProductCategoryNodeDto[]
  38 +}
  39 +
  40 +export interface UsAppLabelPreviewInputVo {
  41 + locationId: string
  42 + labelCode: string
  43 + productId?: string
  44 + baseTime?: string
  45 + printInputJson?: Record<string, unknown>
  46 +}
  47 +
  48 +/** 8.2 预览响应(解包后常见字段,供类型参考) */
  49 +export interface UsAppLabelPreviewDto {
  50 + labelId?: string
  51 + labelLastEdited?: string
  52 + labelCode?: string
  53 + labelSizeText?: string | null
  54 + /** 模板元素 id → 平台录入默认值,与 elements[].id 对应 */
  55 + templateProductDefaultValues?: Record<string, string>
  56 + template?: unknown
  57 +}
  58 +
  59 +export interface UsAppLabelPrintInputVo {
  60 + locationId: string
  61 + labelCode: string
  62 + productId?: string
  63 + printQuantity?: number
  64 + baseTime?: string
  65 + printInputJson?: Record<string, unknown>
  66 + printerId?: string
  67 + printerMac?: string
  68 + printerAddress?: string
  69 +}
  70 +
  71 +export interface UsAppLabelPrintOutputDto {
  72 + taskId: string
  73 + printQuantity: number
  74 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts
1 1 /**
  2 + * 与 vite `server.proxy` 默认 target 一致;H5 开发无 VITE_US_API_BASE 时用于拼图片等静态资源全路径。
  3 + * 修改后端环境时请同步改 vite.config.ts 的 proxy.target。
  4 + */
  5 +export const US_BACKEND_ORIGIN_FALLBACK = 'http://flus-test.3ffoodsafety.com'
  6 +
  7 +/**
2 8 * 美国版后端 API 根地址(不含末尾 /)。
3 9 * - H5 开发:可在 .env.development 留空,配合 vite proxy 走同源 /api
4 10 * - App / 生产:在 .env 中设置 VITE_US_API_BASE,例如 http://192.168.1.10:19001
... ... @@ -7,7 +13,20 @@ export function getApiBaseUrl(): string {
7 13 const fromEnv = (import.meta.env.VITE_US_API_BASE as string | undefined)?.trim()
8 14 if (fromEnv) return fromEnv.replace(/\/$/, '')
9 15 if (import.meta.env.DEV && typeof window !== 'undefined') return ''
10   - return 'http://flus-test.3ffoodsafety.com'
  16 + return US_BACKEND_ORIGIN_FALLBACK
  17 +}
  18 +
  19 +/**
  20 + * 图片等静态资源根(与 API 同域)。H5 开发时 API 仍用相对路径走 Vite /api 代理,
  21 + * 但 /picture/... 若不加域名会请求到 dev server 导致 404,故在此返回后端根域。
  22 + */
  23 +export function getStaticMediaOrigin(): string {
  24 + const apiBase = getApiBaseUrl()
  25 + if (apiBase) return apiBase
  26 + if (import.meta.env.DEV && typeof window !== 'undefined') {
  27 + return US_BACKEND_ORIGIN_FALLBACK
  28 + }
  29 + return ''
11 30 }
12 31  
13 32 export function buildApiUrl(path: string): string {
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts 0 → 100644
  1 +import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
  2 +
  3 +function asRecord(v: unknown): Record<string, unknown> {
  4 + if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown>
  5 + return {}
  6 +}
  7 +
  8 +function normalizeConfig(raw: unknown): Record<string, unknown> {
  9 + if (raw == null) return {}
  10 + if (typeof raw === 'string') {
  11 + const t = raw.trim()
  12 + if (!t) return {}
  13 + try {
  14 + const parsed = JSON.parse(t) as unknown
  15 + if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
  16 + return { ...(parsed as Record<string, unknown>) }
  17 + }
  18 + } catch {
  19 + return {}
  20 + }
  21 + return {}
  22 + }
  23 + const o = asRecord(raw)
  24 + return { ...o }
  25 +}
  26 +
  27 +const TEXT_PRODUCT_PLACEHOLDERS = new Set(['', '文本', 'text', 'Text', 'TEXT', 'Label', 'label'])
  28 +
  29 +/**
  30 + * Web 端设计器里 TEXT_PRODUCT 若为 FIXED 且占位「文本」,预览页用当前商品名覆盖(与列表传入的 productName 一致)。
  31 + */
  32 +export function overlayProductNameOnPreviewTemplate(
  33 + template: SystemLabelTemplate,
  34 + productName: string | undefined
  35 +): SystemLabelTemplate {
  36 + const name = (productName ?? '').trim()
  37 + if (!name) return template
  38 + const elements = (template.elements || []).map((el) => {
  39 + const type = String(el.type || '').toUpperCase()
  40 + if (type !== 'TEXT_PRODUCT') return el
  41 + const raw = String((el.config as any)?.text ?? (el.config as any)?.Text ?? '').trim()
  42 + if (raw && !TEXT_PRODUCT_PLACEHOLDERS.has(raw)) return el
  43 + return {
  44 + ...el,
  45 + config: { ...(el.config || {}), text: name },
  46 + }
  47 + })
  48 + return { ...template, elements }
  49 +}
  50 +
  51 +/**
  52 + * 解析接口 `labelSizeText`(如 `2"x2"`、`6.00x4.00cm`、`2.00*2.00"`)。
  53 + * 无单位后缀时默认 **inch**(与历史 `2"x2"` 一致)。
  54 + */
  55 +export function parseLabelSizeText(
  56 + raw: string | null | undefined
  57 +): { width: number; height: number; unit: 'inch' | 'mm' | 'cm' | 'px' } | null {
  58 + if (raw == null) return null
  59 + let s = String(raw).trim()
  60 + if (!s) return null
  61 + s = s.replace(/["'\u201C\u201D\u2018\u2019\u2032\u2033]/g, '').replace(/\s+/g, '').toLowerCase()
  62 + const m = s.match(/^(\d+(?:\.\d+)?)[*×x](\d+(?:\.\d+)?)(mm|cm|inch|in|px)?$/i)
  63 + if (!m) return null
  64 + const w = Number(m[1])
  65 + const h = Number(m[2])
  66 + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null
  67 + const suf = (m[3] || '').toLowerCase()
  68 + let unit: 'inch' | 'mm' | 'cm' | 'px' = 'inch'
  69 + if (suf === 'mm') unit = 'mm'
  70 + else if (suf === 'cm') unit = 'cm'
  71 + else if (suf === 'px') unit = 'px'
  72 + else if (suf === 'in' || suf === 'inch') unit = 'inch'
  73 + return { width: w, height: h, unit }
  74 +}
  75 +
  76 +/**
  77 + * 用 `labelSizeText` 覆盖模板物理宽高(解析失败则保持原模板)。
  78 + * 注意:App 预览画布应以接口 **template.width / height / unit** 为准(见 preview 页),勿与本函数同时叠加强制覆盖。
  79 + */
  80 +export function applyLabelSizeTextToTemplate(
  81 + template: SystemLabelTemplate,
  82 + labelSizeText: string | null | undefined
  83 +): SystemLabelTemplate {
  84 + const parsed = parseLabelSizeText(labelSizeText)
  85 + if (!parsed) return template
  86 + return {
  87 + ...template,
  88 + unit: parsed.unit,
  89 + width: parsed.width,
  90 + height: parsed.height,
  91 + }
  92 +}
  93 +
  94 +/**
  95 + * 从预览接口响应中取出 `templateProductDefaultValues`(elementId → 字符串,兼容 PascalCase / 嵌套 data)。
  96 + */
  97 +export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: unknown): Record<string, string> {
  98 + const tryLayer = (layer: unknown): Record<string, string> | null => {
  99 + if (layer == null || typeof layer !== 'object' || Array.isArray(layer)) return null
  100 + const L = layer as Record<string, unknown>
  101 + const raw = L.templateProductDefaultValues ?? L.TemplateProductDefaultValues
  102 + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return null
  103 + const out: Record<string, string> = {}
  104 + for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
  105 + if (v == null) out[k] = ''
  106 + else if (typeof v === 'string') out[k] = v
  107 + else if (typeof v === 'number' || typeof v === 'boolean') out[k] = String(v)
  108 + else out[k] = JSON.stringify(v)
  109 + }
  110 + return out
  111 + }
  112 +
  113 + if (payload == null || typeof payload !== 'object') return {}
  114 + const r = payload as Record<string, unknown>
  115 + const nested = r.data ?? r.Data
  116 + const inner =
  117 + nested != null && typeof nested === 'object' && !Array.isArray(nested)
  118 + ? (nested as Record<string, unknown>)
  119 + : null
  120 +
  121 + const a = inner ? tryLayer(inner) : null
  122 + if (a && Object.keys(a).length > 0) return a
  123 + const b = tryLayer(r)
  124 + if (b && Object.keys(b).length > 0) return b
  125 + const c = tryLayer(payload)
  126 + return c && Object.keys(c).length > 0 ? c : {}
  127 +}
  128 +
  129 +/**
  130 + * 将平台录入的默认值合并进模板元素 config,供画布预览(键与 elements[].id 一致)。
  131 + */
  132 +export function applyTemplateProductDefaultValuesToTemplate(
  133 + template: SystemLabelTemplate,
  134 + defaults: Record<string, string>
  135 +): SystemLabelTemplate {
  136 + const keys = Object.keys(defaults)
  137 + if (!keys.length) return template
  138 + const elements = (template.elements || []).map((el) => {
  139 + const byName = (el.elementName ?? '').trim()
  140 + const v =
  141 + defaults[el.id] ?? (byName ? defaults[byName] : undefined)
  142 + if (v === undefined) return el
  143 + const type = String(el.type || '').toUpperCase()
  144 + const cfg = { ...(el.config || {}) } as Record<string, any>
  145 +
  146 + if (type === 'IMAGE' || type === 'LOGO') {
  147 + cfg.src = v
  148 + cfg.url = v
  149 + cfg.Src = v
  150 + cfg.Url = v
  151 + return { ...el, config: cfg }
  152 + }
  153 + if (type === 'BARCODE' || type === 'QRCODE') {
  154 + cfg.data = v
  155 + cfg.Data = v
  156 + return { ...el, config: cfg }
  157 + }
  158 + if (type === 'WEIGHT') {
  159 + cfg.value = v
  160 + cfg.Value = v
  161 + cfg.text = v
  162 + cfg.Text = v
  163 + return { ...el, config: cfg }
  164 + }
  165 +
  166 + cfg.text = v
  167 + cfg.Text = v
  168 + return { ...el, config: cfg }
  169 + })
  170 + return { ...template, elements }
  171 +}
  172 +
  173 +/**
  174 + * 将接口 8.2 返回的 template(或整段 DTO)规范为 SystemLabelTemplate,供打印适配器与预览画布使用。
  175 + */
  176 +export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLabelTemplate | null {
  177 + if (payload == null || typeof payload !== 'object') return null
  178 + const root = payload as Record<string, unknown>
  179 + const t = asRecord(root.template ?? root.Template ?? root)
  180 + const elementsRaw = t.elements ?? t.Elements
  181 + if (!Array.isArray(elementsRaw)) return null
  182 +
  183 + const elements: SystemTemplateElementBase[] = (elementsRaw as unknown[]).map((el, index) => {
  184 + const e = asRecord(el)
  185 + const cfg = normalizeConfig(e.config ?? e.ConfigJson ?? e.configJson)
  186 + const type = String(e.type ?? e.elementType ?? e.ElementType ?? 'TEXT_STATIC')
  187 + const vst = e.valueSourceType ?? e.ValueSourceType
  188 + const ik = e.inputKey ?? e.InputKey
  189 + const en = e.elementName ?? e.ElementName
  190 + return {
  191 + id: String(e.id ?? e.Id ?? `el-${index}`),
  192 + type,
  193 + x: Number(e.x ?? e.posX ?? e.PosX ?? 0),
  194 + y: Number(e.y ?? e.posY ?? e.PosY ?? 0),
  195 + width: Number(e.width ?? e.Width ?? 0),
  196 + height: Number(e.height ?? e.Height ?? 0),
  197 + rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical',
  198 + border: String(e.border ?? e.BorderType ?? e.borderType ?? 'none'),
  199 + config: cfg as Record<string, any>,
  200 + zIndex: Number(e.zIndex ?? e.ZIndex ?? 0),
  201 + orderNum: Number(e.orderNum ?? e.OrderNum ?? index),
  202 + valueSourceType: vst != null ? String(vst) : undefined,
  203 + inputKey: ik != null && String(ik).trim() !== '' ? String(ik).trim() : undefined,
  204 + elementName: en != null && String(en).trim() !== '' ? String(en).trim() : undefined,
  205 + } as SystemTemplateElementBase & { zIndex: number; orderNum: number }
  206 + })
  207 +
  208 + const unitRaw = String(t.unit ?? t.Unit ?? 'inch').toLowerCase()
  209 + const unit = (unitRaw === 'mm' || unitRaw === 'cm' || unitRaw === 'px' ? unitRaw : 'inch') as
  210 + | 'inch'
  211 + | 'mm'
  212 + | 'cm'
  213 + | 'px'
  214 +
  215 + return {
  216 + id: String(t.id ?? t.Id ?? 'preview'),
  217 + name: String(t.name ?? t.Name ?? 'Label'),
  218 + labelType: String(t.labelType ?? t.LabelType ?? ''),
  219 + unit,
  220 + width: Number(t.width ?? t.Width ?? 2),
  221 + height: Number(t.height ?? t.Height ?? 2),
  222 + appliedLocation: String(t.appliedLocation ?? t.AppliedLocation ?? 'ALL'),
  223 + showRuler: !!(t.showRuler ?? t.ShowRuler),
  224 + showGrid: !!(t.showGrid ?? t.ShowGrid),
  225 + elements,
  226 + }
  227 +}
  228 +
  229 +export function sortElementsForPreview(
  230 + elements: SystemTemplateElementBase[]
  231 +): SystemTemplateElementBase[] {
  232 + return [...elements].sort((a, b) => {
  233 + const za = Number((a as any).zIndex ?? 0)
  234 + const zb = Number((b as any).zIndex ?? 0)
  235 + if (za !== zb) return za - zb
  236 + const oa = Number((a as any).orderNum ?? 0)
  237 + const ob = Number((b as any).orderNum ?? 0)
  238 + return oa - ob
  239 + })
  240 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/printInputOptions.ts 0 → 100644
  1 +import type {
  2 + LabelTemplateData,
  3 + SystemLabelTemplate,
  4 + SystemTemplateElementBase,
  5 +} from '../print/types/printer'
  6 +
  7 +/**
  8 + * 打印/预览 printInputJson 的 key:与接口文档一致优先 inputKey,否则 elementName,最后兜底元素 id。
  9 + * 兼容根字段与 config 内 InputKey(部分模板把键写在 config)。
  10 + */
  11 +export function printInputJsonKeyForElement(el: SystemTemplateElementBase): string {
  12 + const cfg = el.config || {}
  13 + const top =
  14 + el.inputKey ??
  15 + (el as { InputKey?: string }).InputKey ??
  16 + cfg.inputKey ??
  17 + cfg.InputKey ??
  18 + el.elementName ??
  19 + (el as { ElementName?: string }).ElementName ??
  20 + cfg.elementName ??
  21 + cfg.ElementName
  22 + const s = top != null ? String(top).trim() : ''
  23 + if (s) return s
  24 + return String(el.id ?? '').trim()
  25 +}
  26 +
  27 +export function isPrintInputOptionsElement(el: SystemTemplateElementBase): boolean {
  28 + const vst = String(el.valueSourceType || '').toUpperCase()
  29 + if (vst !== 'PRINT_INPUT') return false
  30 + const c = el.config || {}
  31 + const it = String(c.inputType ?? c.InputType ?? '').toLowerCase()
  32 + if (it === 'options') return true
  33 + const mid = c.multipleOptionId ?? c.MultipleOptionId
  34 + return typeof mid === 'string' && mid.trim() !== ''
  35 +}
  36 +
  37 +/** PRINT_INPUT 且非多选项字典(无 effective multipleOptionId):需用户在页面上输入 */
  38 +export function isPrintInputFreeFieldElement(el: SystemTemplateElementBase): boolean {
  39 + const vst = String(el.valueSourceType || '').toUpperCase()
  40 + if (vst !== 'PRINT_INPUT') return false
  41 + return !isPrintInputOptionsElement(el)
  42 +}
  43 +
  44 +/** 从模板初始 config 读出已选(接口预览可能已带 selectedOptionValues) */
  45 +export function readSelectionsFromTemplate(template: SystemLabelTemplate): Record<string, string[]> {
  46 + const out: Record<string, string[]> = {}
  47 + for (const el of template.elements || []) {
  48 + if (!isPrintInputOptionsElement(el)) continue
  49 + const raw = el.config?.selectedOptionValues ?? el.config?.SelectedOptionValues
  50 + if (Array.isArray(raw) && raw.length) {
  51 + out[el.id] = raw.map((x: unknown) => String(x))
  52 + }
  53 + }
  54 + return out
  55 +}
  56 +
  57 +/**
  58 + * 将多选结果写回 config(画布与打印用 text 展示「字典名: 值」或 prefix+值)
  59 + */
  60 +/** 从预览模板 config 初始化自由输入(如 WEIGHT 的 value、DATE 已算好的 text) */
  61 +export function readFreeFieldValuesFromTemplate(template: SystemLabelTemplate): Record<string, string> {
  62 + const out: Record<string, string> = {}
  63 + for (const el of template.elements || []) {
  64 + if (!isPrintInputFreeFieldElement(el)) continue
  65 + const c = el.config || {}
  66 + const type = String(el.type || '').toUpperCase()
  67 + let initial = ''
  68 + if (type === 'WEIGHT') {
  69 + const v = c.value ?? c.Value
  70 + if (v != null && String(v).trim() !== '') initial = String(v).trim()
  71 + } else {
  72 + const fmt = String(c.format ?? c.Format ?? '').trim()
  73 + const t = String(c.text ?? c.Text ?? '').trim()
  74 + if (t && (!fmt || t !== fmt)) initial = t
  75 + }
  76 + out[el.id] = initial
  77 + }
  78 + return out
  79 +}
  80 +
  81 +export function ensureFreeFieldKeys(
  82 + template: SystemLabelTemplate,
  83 + existing: Record<string, string>
  84 +): Record<string, string> {
  85 + const out = { ...existing }
  86 + for (const el of template.elements || []) {
  87 + if (!isPrintInputFreeFieldElement(el)) continue
  88 + if (!(el.id in out)) out[el.id] = ''
  89 + }
  90 + return out
  91 +}
  92 +
  93 +/**
  94 + * 将自由输入写回 config:画布用 text 展示;若有 unit 则拼在数值后(如 500g)。
  95 + */
  96 +export function mergePrintInputFreeFields(
  97 + template: SystemLabelTemplate,
  98 + values: Record<string, string>
  99 +): SystemLabelTemplate {
  100 + return {
  101 + ...template,
  102 + elements: (template.elements || []).map((el) => {
  103 + if (!isPrintInputFreeFieldElement(el)) return el
  104 + const cfg = { ...el.config }
  105 + const raw = String(values[el.id] ?? '').trim()
  106 + const unit = String(cfg.unit ?? cfg.Unit ?? '').trim()
  107 + const type = String(el.type || '').toUpperCase()
  108 +
  109 + if (!raw) {
  110 + if (type === 'DATE' || type === 'TIME') {
  111 + const fmt = String(cfg.format ?? cfg.Format ?? '')
  112 + return { ...el, config: { ...cfg, text: fmt } }
  113 + }
  114 + if (type === 'WEIGHT') {
  115 + return { ...el, config: { ...cfg, text: '', value: '' } }
  116 + }
  117 + const ph = String(
  118 + cfg.placeholder ?? cfg.Placeholder ?? cfg.defaultValue ?? cfg.DefaultValue ?? ''
  119 + ).trim()
  120 + return { ...el, config: { ...cfg, text: ph } }
  121 + }
  122 +
  123 + const display = unit && !raw.endsWith(unit) ? `${raw}${unit}` : raw
  124 + const next = { ...cfg, text: display } as Record<string, any>
  125 + if (type === 'WEIGHT') {
  126 + next.value = raw
  127 + }
  128 + return { ...el, config: next }
  129 + }),
  130 + }
  131 +}
  132 +
  133 +export function mergePrintOptionSelections(
  134 + template: SystemLabelTemplate,
  135 + selections: Record<string, string[]>,
  136 + dictNames: Record<string, string>
  137 +): SystemLabelTemplate {
  138 + return {
  139 + ...template,
  140 + elements: (template.elements || []).map((el) => {
  141 + if (!isPrintInputOptionsElement(el)) return el
  142 + const sel = selections[el.id]
  143 + if (!sel || sel.length === 0) return el
  144 + const cfg = { ...el.config }
  145 + cfg.selectedOptionValues = sel
  146 + const dictLabel = dictNames[el.id] || 'Options'
  147 + const prefix = String(cfg.prefix ?? cfg.Prefix ?? '').trim()
  148 + const answers = sel.join(', ')
  149 + cfg.text = prefix ? `${prefix}${answers}` : `${dictLabel}: ${answers}`
  150 + return { ...el, config: cfg }
  151 + }),
  152 + }
  153 +}
  154 +
  155 +export function printInputJsonFromSelections(
  156 + template: SystemLabelTemplate,
  157 + selections: Record<string, string[]>
  158 +): Record<string, unknown> {
  159 + const out: Record<string, unknown> = {}
  160 + for (const el of template.elements || []) {
  161 + if (!isPrintInputOptionsElement(el)) continue
  162 + const sel = selections[el.id]
  163 + if (!sel || !sel.length) continue
  164 + const key = printInputJsonKeyForElement(el)
  165 + if (!key) continue
  166 + out[key] = sel.length === 1 ? sel[0] : [...sel]
  167 + }
  168 + return out
  169 +}
  170 +
  171 +export function printInputJsonFromFreeFields(
  172 + template: SystemLabelTemplate,
  173 + values: Record<string, string>
  174 +): Record<string, unknown> {
  175 + const out: Record<string, unknown> = {}
  176 + for (const el of template.elements || []) {
  177 + if (!isPrintInputFreeFieldElement(el)) continue
  178 + const key = printInputJsonKeyForElement(el)
  179 + if (!key) continue
  180 + const v = String(values[el.id] ?? '').trim()
  181 + if (v === '') continue
  182 + out[key] = v
  183 + }
  184 + return out
  185 +}
  186 +
  187 +export function buildPrintInputJson(
  188 + template: SystemLabelTemplate,
  189 + optionSelections: Record<string, string[]>,
  190 + freeFieldValues: Record<string, string>
  191 +): Record<string, unknown> {
  192 + return {
  193 + ...printInputJsonFromSelections(template, optionSelections),
  194 + ...printInputJsonFromFreeFields(template, freeFieldValues),
  195 + }
  196 +}
  197 +
  198 +/** 本地打印机 / dataJson:与 printInputJson 同键,供模板 {{key}} 或 native 层合并 */
  199 +export function printInputJsonToLabelTemplateData(pj: Record<string, unknown>): LabelTemplateData {
  200 + const out: LabelTemplateData = {}
  201 + for (const k of Object.keys(pj)) {
  202 + const v = pj[k]
  203 + if (v == null) continue
  204 + if (typeof v === 'string' || typeof v === 'number') {
  205 + out[k] = v
  206 + } else if (Array.isArray(v)) {
  207 + out[k] = v.map((x) => String(x)).join(', ')
  208 + } else {
  209 + out[k] = JSON.stringify(v)
  210 + }
  211 + }
  212 + return out
  213 +}
  214 +
  215 +/**
  216 + * PRINT_INPUT 且为多选项字典:未至少选一项则不可打印。
  217 + */
  218 +export function validatePrintInputOptionsBeforePrint(
  219 + template: SystemLabelTemplate,
  220 + selections: Record<string, string[]>,
  221 + dictLabels: Record<string, string>
  222 +): string | null {
  223 + for (const el of template.elements || []) {
  224 + if (!isPrintInputOptionsElement(el)) continue
  225 + const sel = selections[el.id]
  226 + if (!sel || sel.length === 0) {
  227 + const name =
  228 + dictLabels[el.id] ||
  229 + el.elementName ||
  230 + el.inputKey ||
  231 + 'options'
  232 + return `Please select “${name}” before printing.`
  233 + }
  234 + }
  235 + return null
  236 +}
  237 +
  238 +/**
  239 + * PRINT_INPUT 自由字段:未填写则不可打印。
  240 + */
  241 +export function validatePrintInputFreeFieldsBeforePrint(
  242 + template: SystemLabelTemplate,
  243 + values: Record<string, string>
  244 +): string | null {
  245 + for (const el of template.elements || []) {
  246 + if (!isPrintInputFreeFieldElement(el)) continue
  247 + const raw = String(values[el.id] ?? '').trim()
  248 + if (raw === '') {
  249 + const name = el.elementName || el.inputKey || 'field'
  250 + return `Please fill in “${name}” before printing.`
  251 + }
  252 + }
  253 + return null
  254 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts 0 → 100644
  1 +import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
  2 +import { resolveMediaUrlForApp } from '../resolveMediaUrl'
  3 +import { sortElementsForPreview } from './normalizePreviewTemplate'
  4 +
  5 +/** 与 Web LabelCanvas.unitToPx 一致:cm 用 37.8px/inch,保证与后台模板坐标系一致 */
  6 +const PX_PER_CM = 37.8
  7 +const PX_PER_INCH = 96
  8 +
  9 +function toCanvasPx(value: number, unit: string): number {
  10 + const u = String(unit || 'inch').toLowerCase()
  11 + if (u === 'mm') return (value / 25.4) * PX_PER_INCH
  12 + if (u === 'cm') return value * PX_PER_CM
  13 + if (u === 'px') return value
  14 + return value * PX_PER_INCH
  15 +}
  16 +
  17 +function cfgStr(config: Record<string, any>, keys: string[], fallback = ''): string {
  18 + for (const k of keys) {
  19 + const v = config?.[k]
  20 + if (v != null && v !== '') return String(v)
  21 + }
  22 + return fallback
  23 +}
  24 +
  25 +/** 前缀在正文前;若正文已以前缀开头则不再重复拼接 */
  26 +function applyConfigPrefix(config: Record<string, any>, body: string): string {
  27 + const prefix = String(config.prefix ?? config.Prefix ?? '')
  28 + if (!prefix) return body
  29 + const b = body ?? ''
  30 + if (b.startsWith(prefix)) return b
  31 + return `${prefix}${b}`
  32 +}
  33 +
  34 +function readFontSize(config: Record<string, any>): number {
  35 + const n = Number(config.fontSize ?? config.FontSize ?? 14)
  36 + return Math.max(6, Math.round(Number.isFinite(n) ? n : 14))
  37 +}
  38 +
  39 +function readTextAlign(config: Record<string, any>): string {
  40 + return String(config.textAlign ?? config.TextAlign ?? 'left').toLowerCase()
  41 +}
  42 +
  43 +function readFillColor(config: Record<string, any>): string {
  44 + return String(config.color ?? config.Color ?? '#111827')
  45 +}
  46 +
  47 +/** 按元素框宽度估算每行最大字符数(等宽近似,兼容中英文) */
  48 +function maxCharsPerLine(innerWidthPx: number, fontSize: number): number {
  49 + if (innerWidthPx <= 4) return 8
  50 + const approx = Math.max(0.45, Math.min(0.75, 0.55))
  51 + return Math.max(4, Math.floor(innerWidthPx / (fontSize * approx)))
  52 +}
  53 +
  54 +function wrapTextToWidth(text: string, maxChars: number): string[] {
  55 + const lines = String(text).split(/\r?\n/)
  56 + const out: string[] = []
  57 + for (const line of lines) {
  58 + if (line.length <= maxChars) {
  59 + out.push(line)
  60 + continue
  61 + }
  62 + for (let i = 0; i < line.length; i += maxChars) {
  63 + out.push(line.slice(i, i + maxChars))
  64 + }
  65 + }
  66 + return out.length ? out : ['']
  67 +}
  68 +
  69 +function previewTextForElement(element: SystemTemplateElementBase): string {
  70 + const type = String(element.type || '').toUpperCase()
  71 + const config = element.config || {}
  72 + if (type === 'QRCODE' || type === 'BARCODE') {
  73 + return cfgStr(config, ['data', 'Data', 'value', 'Value'])
  74 + }
  75 + const vst = String(element.valueSourceType || '').toUpperCase()
  76 + const inputType = String(config.inputType ?? config.InputType ?? '').toLowerCase()
  77 + const hasDict = !!(config.multipleOptionId ?? config.MultipleOptionId)
  78 + if (vst === 'PRINT_INPUT' && (inputType === 'options' || hasDict)) {
  79 + const rawSel = config.selectedOptionValues ?? config.SelectedOptionValues
  80 + const arr = Array.isArray(rawSel) ? rawSel.map((x: unknown) => String(x)) : []
  81 + const txt = cfgStr(config, ['text', 'Text'], '')
  82 + if (arr.length > 0 && txt.trim()) return txt
  83 + if (arr.length > 0) {
  84 + const joined = arr.join(', ')
  85 + return applyConfigPrefix(config, joined)
  86 + }
  87 + const hint = txt.trim() || 'Select below'
  88 + return applyConfigPrefix(config, hint)
  89 + }
  90 + if (vst === 'PRINT_INPUT' && !(inputType === 'options' || hasDict)) {
  91 + let body = cfgStr(config, ['text', 'Text'], '')
  92 + if (!body.trim()) body = cfgStr(config, ['value', 'Value'], '')
  93 + if (!body.trim()) body = cfgStr(config, ['format', 'Format', 'placeholder', 'Placeholder'], '')
  94 + const unit = String(config.unit ?? config.Unit ?? '').trim()
  95 + if (unit && body.trim() && !body.endsWith(unit)) body = `${body}${unit}`
  96 + return applyConfigPrefix(config, body)
  97 + }
  98 + const body = cfgStr(config, [
  99 + 'text',
  100 + 'Text',
  101 + 'format',
  102 + 'Format',
  103 + 'content',
  104 + 'Content',
  105 + 'value',
  106 + 'Value',
  107 + 'displayText',
  108 + 'displayValue',
  109 + 'defaultValue',
  110 + 'placeholder',
  111 + ])
  112 + return applyConfigPrefix(config, body)
  113 +}
  114 +
  115 +function isGraphicOnlyType(type: string): boolean {
  116 + return type === 'IMAGE' || type === 'LOGO' || type === 'BARCODE' || type === 'QRCODE'
  117 +}
  118 +
  119 +function previewExportPixelRatio(): number {
  120 + try {
  121 + const pr = uni.getSystemInfoSync().pixelRatio
  122 + return Math.min(2.5, Math.max(1, typeof pr === 'number' && pr > 0 ? pr : 2))
  123 + } catch {
  124 + return 2
  125 + }
  126 +}
  127 +
  128 +/**
  129 + * 将模板绘制到 canvas,并导出临时路径供 <image> 展示。
  130 + */
  131 +export function renderLabelPreviewToTempPath(
  132 + canvasId: string,
  133 + componentInstance: any,
  134 + template: SystemLabelTemplate,
  135 + maxDisplayWidthPx = 720
  136 +): Promise<string> {
  137 + const unit = template.unit || 'inch'
  138 + const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit)))
  139 + const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit)))
  140 + const scale = Math.min(1, maxDisplayWidthPx / cw)
  141 + const outW = Math.max(1, Math.round(cw * scale))
  142 + const outH = Math.max(1, Math.round(ch * scale))
  143 + const exportPr = previewExportPixelRatio()
  144 +
  145 + const sorted = sortElementsForPreview(template.elements || [])
  146 +
  147 + return new Promise((resolve, reject) => {
  148 + const ctx = uni.createCanvasContext(canvasId, componentInstance)
  149 + ctx.setFillStyle('#ffffff')
  150 + ctx.scale(scale, scale)
  151 + ctx.fillRect(0, 0, cw, ch)
  152 +
  153 + const drawRest = (index: number) => {
  154 + if (index >= sorted.length) {
  155 + ctx.draw(false, () => {
  156 + setTimeout(() => {
  157 + uni.canvasToTempFilePath(
  158 + {
  159 + canvasId,
  160 + width: outW,
  161 + height: outH,
  162 + destWidth: Math.round(outW * exportPr),
  163 + destHeight: Math.round(outH * exportPr),
  164 + success: (res) => resolve(res.tempFilePath),
  165 + fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath failed')),
  166 + },
  167 + componentInstance
  168 + )
  169 + }, 120)
  170 + })
  171 + return
  172 + }
  173 +
  174 + const el = sorted[index]
  175 + const type = String(el.type || '').toUpperCase()
  176 + const config = el.config || {}
  177 + const x = Number(el.x) || 0
  178 + const y = Number(el.y) || 0
  179 + const w = Math.max(0, Number(el.width) || 0)
  180 + const h = Math.max(0, Number(el.height) || 0)
  181 +
  182 + const next = () => drawRest(index + 1)
  183 +
  184 + if (type === 'IMAGE' || type === 'LOGO') {
  185 + const src = resolveMediaUrlForApp(cfgStr(config, ['src', 'url', 'Src', 'Url']))
  186 + if (src) {
  187 + uni.getImageInfo({
  188 + src,
  189 + success: (info) => {
  190 + try {
  191 + ctx.drawImage(info.path, x, y, w || info.width, h || info.height)
  192 + } catch (_) {
  193 + ctx.setStrokeStyle('#cccccc')
  194 + ctx.setLineWidth(1)
  195 + ctx.strokeRect(x, y, w || 80, h || 40)
  196 + }
  197 + next()
  198 + },
  199 + fail: () => {
  200 + ctx.setStrokeStyle('#cccccc')
  201 + ctx.strokeRect(x, y, w || 80, h || 40)
  202 + next()
  203 + },
  204 + })
  205 + return
  206 + }
  207 + next()
  208 + return
  209 + }
  210 +
  211 + if (type === 'QRCODE' || type === 'BARCODE') {
  212 + ctx.setFillStyle('#f3f4f6')
  213 + ctx.fillRect(x, y, w || 60, h || 60)
  214 + ctx.setStrokeStyle('#9ca3af')
  215 + ctx.setLineWidth(1)
  216 + ctx.strokeRect(x, y, w || 60, h || 60)
  217 + const d = previewTextForElement(el)
  218 + ctx.setFillStyle('#374151')
  219 + ctx.setFontSize(10)
  220 + const label = type === 'QRCODE' ? 'QR' : 'BC'
  221 + ctx.fillText(label, x + 4, y + 14)
  222 + if (d) {
  223 + const short = d.length > 12 ? `${d.slice(0, 10)}…` : d
  224 + ctx.fillText(short, x + 4, y + 28)
  225 + }
  226 + next()
  227 + return
  228 + }
  229 +
  230 + const line = String(el.border || '').toLowerCase()
  231 + if (line === 'line' || line === 'solid') {
  232 + ctx.setStrokeStyle('#111827')
  233 + ctx.setLineWidth(1)
  234 + ctx.strokeRect(x, y, w, h)
  235 + } else if (line === 'dotted') {
  236 + ctx.setStrokeStyle('#9ca3af')
  237 + ctx.setLineWidth(1)
  238 + if (typeof (ctx as any).setLineDash === 'function') {
  239 + ;(ctx as any).setLineDash([3, 3], 0)
  240 + ctx.strokeRect(x, y, w, h)
  241 + ;(ctx as any).setLineDash([], 0)
  242 + } else {
  243 + ctx.strokeRect(x, y, w, h)
  244 + }
  245 + }
  246 +
  247 + const text = previewTextForElement(el)
  248 + if (text && !isGraphicOnlyType(type)) {
  249 + const fontSize = readFontSize(config)
  250 + const color = readFillColor(config)
  251 + ctx.setFillStyle(color)
  252 + ctx.setFontSize(fontSize)
  253 + const fontWeight = String(config.fontWeight ?? config.FontWeight ?? 'normal').toLowerCase()
  254 + if (typeof (ctx as any).setFontWeight === 'function') {
  255 + ;(ctx as any).setFontWeight(fontWeight === 'bold' || fontWeight === '700' ? 'bold' : 'normal')
  256 + }
  257 + const align = readTextAlign(config)
  258 + const pad = 2
  259 + const innerW = Math.max(0, w - pad * 2)
  260 + const innerH = Math.max(fontSize, h - pad * 2)
  261 + let tx = x + pad
  262 + if (align === 'center') tx = x + w / 2
  263 + else if (align === 'right') tx = x + w - pad
  264 + ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left')
  265 + const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15))
  266 + const maxChars = maxCharsPerLine(innerW, fontSize)
  267 + const lines = wrapTextToWidth(text, maxChars)
  268 + const maxLines =
  269 + innerH >= fontSize ? Math.max(1, Math.floor(innerH / lineHeight)) : lines.length
  270 + const startY = y + pad + fontSize
  271 + lines.slice(0, maxLines).forEach((ln, li) => {
  272 + ctx.fillText(ln, tx, startY + li * lineHeight)
  273 + })
  274 + ctx.setTextAlign('left')
  275 + }
  276 +
  277 + next()
  278 + }
  279 +
  280 + drawRest(0)
  281 + })
  282 +}
  283 +
  284 +export function getPreviewCanvasCssSize(template: SystemLabelTemplate, maxDisplayWidthPx = 720): {
  285 + width: number
  286 + height: number
  287 +} {
  288 + const unit = template.unit || 'inch'
  289 + const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit)))
  290 + const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit)))
  291 + const scale = Math.min(1, maxDisplayWidthPx / cw)
  292 + return {
  293 + width: Math.max(1, Math.round(cw * scale)),
  294 + height: Math.max(1, Math.round(ch * scale)),
  295 + }
  296 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/pagedList.ts 0 → 100644
  1 +import { unwrapApiPayload } from './usAppApiRequest'
  2 +
  3 +/** 解析分页 JSON:兼容 items / Items、camelCase / PascalCase、ABP 包装 */
  4 +export function extractPagedItems<T>(responseBody: unknown): {
  5 + items: T[]
  6 + totalCount: number
  7 + pageIndex: number
  8 + pageSize: number
  9 +} {
  10 + const raw = unwrapApiPayload<Record<string, unknown>>(responseBody)
  11 + if (raw == null || typeof raw !== 'object') {
  12 + return { items: [], totalCount: 0, pageIndex: 1, pageSize: 0 }
  13 + }
  14 + const itemsRaw = raw.items ?? raw.Items
  15 + const items = Array.isArray(itemsRaw) ? (itemsRaw as T[]) : []
  16 + const totalCount = Number(raw.totalCount ?? raw.TotalCount ?? items.length) || 0
  17 + const pageIndex = Number(raw.pageIndex ?? raw.PageIndex ?? 1) || 1
  18 + const pageSize = Number(raw.pageSize ?? raw.PageSize ?? items.length) || 0
  19 + return { items, totalCount, pageIndex, pageSize }
  20 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/printerReadiness.ts 0 → 100644
  1 +import {
  2 + getBluetoothConnection,
  3 + getPrinterType,
  4 + isBuiltinConnected,
  5 +} from './printerConnection'
  6 +
  7 +/** 同步:是否已选择并保存了可用的打印机连接(蓝牙已配对写入 storage / 或内置) */
  8 +export function isPrinterReadySync(): boolean {
  9 + const type = getPrinterType()
  10 + if (type === 'builtin') return isBuiltinConnected()
  11 + if (type === 'bluetooth') return !!getBluetoothConnection()
  12 + return false
  13 +}
  14 +
  15 +/**
  16 + * 检测系统蓝牙是否开启(APP 端)。H5 返回 false。
  17 + * 用于在「未选打印机」时区分是否需先开蓝牙(仍统一用同一套文案弹窗)。
  18 + */
  19 +export function checkBluetoothAdapterAvailable(): Promise<boolean> {
  20 + return new Promise((resolve) => {
  21 + // #ifdef APP-PLUS
  22 + uni.getBluetoothAdapterState({
  23 + success: (res) => {
  24 + resolve(res.available === true)
  25 + },
  26 + fail: () => {
  27 + resolve(false)
  28 + },
  29 + })
  30 + // #endif
  31 + // #ifndef APP-PLUS
  32 + resolve(false)
  33 + // #endif
  34 + })
  35 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
... ... @@ -138,6 +138,28 @@ function formatPriceValue (
138 138 return `${prefix}${value}${suffix}`
139 139 }
140 140  
  141 +/** WEIGHT / DATE / TIME / DURATION:画布已把展示写入 config.text;此处兜底 value+unit、format */
  142 +function resolvePlainTextLikeElement (
  143 + element: SystemTemplateElementBase,
  144 + data: LabelTemplateData
  145 +): string {
  146 + const config = element.config || {}
  147 + const t = getConfigString(config, ['text', 'Text'])
  148 + if (t) return applyTemplateData(t, data)
  149 + const type = String(element.type || '').toUpperCase()
  150 + if (type === 'WEIGHT') {
  151 + const v = getConfigString(config, ['value', 'Value'])
  152 + const u = getConfigString(config, ['unit', 'Unit'])
  153 + if (!v && !u) return ''
  154 + if (v && u && !v.endsWith(u)) return `${v}${u}`
  155 + return v || u
  156 + }
  157 + if (type === 'DATE' || type === 'TIME' || type === 'DURATION') {
  158 + return getConfigString(config, ['format', 'Format'])
  159 + }
  160 + return ''
  161 +}
  162 +
141 163 function resolveElementText (
142 164 element: SystemTemplateElementBase,
143 165 data: LabelTemplateData
... ... @@ -287,8 +309,17 @@ function buildTscTemplate (
287 309 const config = element.config || {}
288 310 const type = String(element.type || '').toUpperCase()
289 311  
290   - if (type.startsWith('TEXT_')) {
291   - const text = resolveElementText(element, data)
  312 + const renderAsTextBlock =
  313 + type.startsWith('TEXT_') ||
  314 + type === 'WEIGHT' ||
  315 + type === 'DATE' ||
  316 + type === 'TIME' ||
  317 + type === 'DURATION'
  318 +
  319 + if (renderAsTextBlock) {
  320 + const text = type.startsWith('TEXT_')
  321 + ? resolveElementText(element, data)
  322 + : resolvePlainTextLikeElement(element, data)
292 323 if (!text) return
293 324 const scale = resolveTextScale(getConfigNumber(config, ['fontSize'], 14), dpi)
294 325 const align = resolveElementAlign(element, pageWidth)
... ... @@ -404,8 +435,17 @@ function buildEscTemplate (
404 435 const type = String(element.type || '').toUpperCase()
405 436 const align = toEscAlign(resolveElementAlign(element, pageWidth))
406 437  
407   - if (type.startsWith('TEXT_')) {
408   - const text = resolveElementText(element, data)
  438 + const renderAsTextBlockEsc =
  439 + type.startsWith('TEXT_') ||
  440 + type === 'WEIGHT' ||
  441 + type === 'DATE' ||
  442 + type === 'TIME' ||
  443 + type === 'DURATION'
  444 +
  445 + if (renderAsTextBlockEsc) {
  446 + const text = type.startsWith('TEXT_')
  447 + ? resolveElementText(element, data)
  448 + : resolvePlainTextLikeElement(element, data)
409 449 if (!text) return
410 450 const fontSize = getConfigNumber(config, ['fontSize'], 14)
411 451 const scale = fontSize >= 28 ? 2 : 1
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
... ... @@ -60,6 +60,10 @@ export interface SystemTemplateElementBase {
60 60 rotation?: SystemTemplateRotation
61 61 border?: string
62 62 config: Record<string, any>
  63 + /** 接口 8.2 template.elements:FIXED / AUTO_DB / PRINT_INPUT */
  64 + valueSourceType?: string
  65 + inputKey?: string
  66 + elementName?: string
63 67 }
64 68  
65 69 export interface SystemLabelTemplate {
... ...
美国版/Food Labeling Management App UniApp/src/utils/resolveMediaUrl.ts 0 → 100644
  1 +import { buildApiUrl, getStaticMediaOrigin } from './apiBase'
  2 +
  3 +/** 相对路径拼后端根(含 H5 开发时的静态资源域名);绝对 URL / data URL 原样返回 */
  4 +export function resolveMediaUrlForApp(stored: string | null | undefined): string {
  5 + const s = (stored ?? '').trim()
  6 + if (!s) return ''
  7 + if (s.startsWith('data:')) return s
  8 + if (/^https?:\/\//i.test(s)) return s
  9 + const path = s.startsWith('/') ? s : `/${s}`
  10 + const mediaOrigin = getStaticMediaOrigin()
  11 + if (mediaOrigin) return `${mediaOrigin.replace(/\/$/, '')}${path}`
  12 + return buildApiUrl(path)
  13 +}
... ...
美国版/Food Labeling Management App UniApp/vite.config.ts
1 1 import { defineConfig } from "vite";
2 2 import uni from "@dcloudio/vite-plugin-uni";
3 3  
  4 +/** 与 src/utils/apiBase.ts 中 US_BACKEND_ORIGIN_FALLBACK 保持一致 */
  5 +const US_DEV_BACKEND = "http://flus-test.3ffoodsafety.com";
  6 +
4 7 // 仅 App:相对路径,避免打包后 www/_uniappview.html 无法打开
5 8 // https://vitejs.dev/config/
6 9 export default defineConfig({
... ... @@ -10,7 +13,12 @@ export default defineConfig({
10 13 open: "/",
11 14 proxy: {
12 15 "/api": {
13   - target: "http://flus-test.3ffoodsafety.com",
  16 + target: US_DEV_BACKEND,
  17 + changeOrigin: true,
  18 + },
  19 + /** 若某处仍使用相对路径 /picture(未走 getStaticMediaOrigin),可经代理访问后端静态资源 */
  20 + "/picture": {
  21 + target: US_DEV_BACKEND,
14 22 changeOrigin: true,
15 23 },
16 24 },
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/PagedQueryConvention.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Helpers;
  2 +
  3 +/// <summary>
  4 +/// 与平台端约定:列表接口 Query 的 <c>SkipCount</c> 表示 SqlSugar 分页<strong>页码(从 1 起)</strong>,
  5 +/// 不是 0 基 offset。第一页应传 <c>SkipCount=1</c>。
  6 +/// </summary>
  7 +public static class PagedQueryConvention
  8 +{
  9 + public static int PageIndexFromSkipCount(int skipCount) => skipCount <= 0 ? 1 : skipCount;
  10 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs
1 1 using System.Text.Json;
  2 +using FoodLabeling.Application.Helpers;
2 3 using FoodLabeling.Application.Contracts.Dtos.Common;
3 4 using FoodLabeling.Application.Contracts.Dtos.Label;
4 5 using FoodLabeling.Application.Contracts.Dtos.LabelTemplate;
... ... @@ -132,7 +133,7 @@ public class LabelAppService : ApplicationService, ILabelAppService
132 133 }).ToList();
133 134  
134 135 var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
135   - var pageIndex = pageSize <= 0 ? 1 : (input.SkipCount / pageSize) + 1;
  136 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
136 137 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
137 138  
138 139 return new PagedResultWithPageDto<LabelGetListOutputDto>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelCategoryAppService.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using FoodLabeling.Application.Contracts.Dtos.Common;
2 3 using FoodLabeling.Application.Contracts.Dtos.LabelCategory;
3 4 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -182,7 +183,7 @@ public class LabelCategoryAppService : ApplicationService, ILabelCategoryAppServ
182 183 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
183 184 {
184 185 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
185   - var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
  186 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
186 187 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
187 188 return new PagedResultWithPageDto<T>
188 189 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelMultipleOptionAppService.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using FoodLabeling.Application.Contracts.Dtos.Common;
2 3 using FoodLabeling.Application.Contracts.Dtos.LabelMultipleOption;
3 4 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -158,7 +159,7 @@ public class LabelMultipleOptionAppService : ApplicationService, ILabelMultipleO
158 159 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
159 160 {
160 161 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
161   - var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
  162 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
162 163 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
163 164 return new PagedResultWithPageDto<T>
164 165 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs
1 1 using System.Text.Json;
  2 +using FoodLabeling.Application.Helpers;
2 3 using FoodLabeling.Application.Contracts.Dtos.Common;
3 4 using FoodLabeling.Application.Contracts.Dtos.LabelTemplate;
4 5 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -399,7 +400,7 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
399 400 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
400 401 {
401 402 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
402   - var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
  403 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
403 404 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
404 405 return new PagedResultWithPageDto<T>
405 406 {
... ... @@ -414,7 +415,7 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
414 415 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, RefAsync<int> total, List<T> items)
415 416 {
416 417 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
417   - var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
  418 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
418 419 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total.Value / (double)pageSize);
419 420 return new PagedResultWithPageDto<T>
420 421 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTypeAppService.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using FoodLabeling.Application.Contracts.Dtos.Common;
2 3 using FoodLabeling.Application.Contracts.Dtos.LabelType;
3 4 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -177,7 +178,7 @@ public class LabelTypeAppService : ApplicationService, ILabelTypeAppService
177 178 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
178 179 {
179 180 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
180   - var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
  181 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
181 182 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
182 183 return new PagedResultWithPageDto<T>
183 184 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using FoodLabeling.Application.Contracts.Dtos.Location;
2 3 using FoodLabeling.Application.Contracts.IServices;
3 4 using FoodLabeling.Domain.Entities;
... ... @@ -82,7 +83,7 @@ public class LocationAppService : ApplicationService, ILocationAppService
82 83 }).ToList();
83 84  
84 85 var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
85   - var pageIndex = pageSize <= 0 ? 1 : (input.SkipCount / pageSize) + 1;
  86 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
86 87 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
87 88  
88 89 return new PagedResultWithPageDto<LocationGetListOutputDto>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using FoodLabeling.Application.Contracts.Dtos.Common;
2 3 using FoodLabeling.Application.Contracts.Dtos.Product;
3 4 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -207,7 +208,7 @@ public class ProductAppService : ApplicationService, IProductAppService
207 208 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
208 209 {
209 210 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
210   - var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
  211 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
211 212 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
212 213 return new PagedResultWithPageDto<T>
213 214 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductLocationAppService.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using FoodLabeling.Application.Contracts.Dtos.Common;
2 3 using FoodLabeling.Application.Contracts.Dtos.ProductLocation;
3 4 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -319,7 +320,7 @@ public class ProductLocationAppService : ApplicationService, IProductLocationApp
319 320 private static PagedResultWithPageDto<T> BuildPagedResult<T>(int skipCount, int maxResultCount, int total, List<T> items)
320 321 {
321 322 var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
322   - var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
  323 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(skipCount);
323 324 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
324 325 return new PagedResultWithPageDto<T>
325 326 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.RbacRole;
  2 +using FoodLabeling.Application.Helpers;
2 3 using FoodLabeling.Application.Contracts.Dtos.Common;
3 4 using FoodLabeling.Application.Contracts.IServices;
4 5 using FoodLabeling.Application.Services.DbModels;
... ... @@ -72,7 +73,7 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
72 73 }).ToList();
73 74  
74 75 var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
75   - var pageIndex = pageSize <= 0 ? 1 : (input.SkipCount / pageSize) + 1;
  76 + var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
76 77 var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
77 78  
78 79 return new PagedResultWithPageDto<RbacRoleGetListOutputDto>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
  1 +using FoodLabeling.Application.Helpers;
1 2 using FoodLabeling.Application.Contracts.Dtos.Common;
2 3 using FoodLabeling.Application.Contracts.Dtos.TeamMember;
3 4 using FoodLabeling.Application.Contracts.IServices;
... ... @@ -41,7 +42,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
41 42 /// </summary>
42 43 public async Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input)
43 44 {
44   - var pageIndex = input.SkipCount / input.MaxResultCount + 1;
  45 + var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
45 46 var pageSize = input.MaxResultCount;
46 47 var keyword = input.Keyword?.Trim();
47 48  
... ...
美国版/Food Labeling Management Platform/build/assets/index-BaZIqfDW.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-C_mEdGxy.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
... ... @@ -5,7 +5,7 @@
5 5 <meta charset="UTF-8" />
6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 7 <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-C_mEdGxy.js"></script>
  8 + <script type="module" crossorigin src="/assets/index-BaZIqfDW.js"></script>
9 9 <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css">
10 10 </head>
11 11  
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
... ... @@ -30,6 +30,7 @@ import { Switch } from &quot;../ui/switch&quot;;
30 30 import { Badge } from "../ui/badge";
31 31 import { Plus, Edit, MoreHorizontal, Trash2 } from "lucide-react";
32 32 import { toast } from "sonner";
  33 +import { skipCountForPage } from "../../lib/paginationQuery";
33 34 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
34 35 import {
35 36 Pagination,
... ... @@ -102,8 +103,7 @@ export function LabelCategoriesView() {
102 103  
103 104 setLoading(true);
104 105 try {
105   - // skipCount 从 0 开始,前端分页从 1 开始,需要转换
106   - const skipCount = (pageIndex - 1) * pageSize;
  106 + const skipCount = skipCountForPage(pageIndex);
107 107 const res = await getLabelCategories(
108 108 {
109 109 skipCount,
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx 0 → 100644
  1 +import React, { useCallback, useEffect, useMemo, useState } from 'react';
  2 +import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
  3 +import { toast } from 'sonner';
  4 +import { Button } from '../ui/button';
  5 +import { Input } from '../ui/input';
  6 +import {
  7 + Table,
  8 + TableBody,
  9 + TableCell,
  10 + TableHead,
  11 + TableHeader,
  12 + TableRow,
  13 +} from '../ui/table';
  14 +import { SearchableSelect } from '../ui/searchable-select';
  15 +import { ImageUrlUpload } from '../ui/image-url-upload';
  16 +import { getLabelTemplate, updateLabelTemplate } from '../../services/labelTemplateService';
  17 +import { getProducts } from '../../services/productService';
  18 +import { getLabelTypes } from '../../services/labelTypeService';
  19 +import { skipCountForPage } from '../../lib/paginationQuery';
  20 +import type { ElementType, LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate';
  21 +import {
  22 + appliedLocationToEditor,
  23 + dataEntryColumnLabel,
  24 + isDataEntryTableColumnElement,
  25 + labelElementsToApiPayload,
  26 + sortTemplateElementsForDisplay,
  27 +} from '../../types/labelTemplate';
  28 +import type { ProductDto } from '../../types/product';
  29 +import type { LabelTypeDto } from '../../types/labelType';
  30 +
  31 +export type TemplateDataEntryRow = {
  32 + id: string;
  33 + productId: string;
  34 + labelTypeId: string;
  35 + /** elementId -> 管理端录入的关联/默认值(真正打印时输入仍在 App) */
  36 + fieldValues: Record<string, string>;
  37 +};
  38 +
  39 +function newRowId(): string {
  40 + try {
  41 + return crypto.randomUUID();
  42 + } catch {
  43 + return `row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
  44 + }
  45 +}
  46 +
  47 +function DataEntryValueCell({
  48 + elementType,
  49 + value,
  50 + onValueChange,
  51 +}: {
  52 + elementType: ElementType;
  53 + value: string;
  54 + onValueChange: (next: string) => void;
  55 +}) {
  56 + if (elementType === 'IMAGE') {
  57 + return (
  58 + <ImageUrlUpload
  59 + value={value}
  60 + onChange={onValueChange}
  61 + uploadSubDir="label-template-data"
  62 + oneImageOnly
  63 + boxClassName="max-w-[160px]"
  64 + hint="Upload stores full URL/path for save."
  65 + />
  66 + );
  67 + }
  68 + return (
  69 + <Input
  70 + value={value}
  71 + onChange={(e) => onValueChange(e.target.value)}
  72 + placeholder="—"
  73 + className="h-10 border-gray-300"
  74 + />
  75 + );
  76 +}
  77 +
  78 +export function LabelTemplateDataEntryView({
  79 + templateCode,
  80 + onBack,
  81 +}: {
  82 + templateCode: string;
  83 + onBack: () => void;
  84 +}) {
  85 + const [loading, setLoading] = useState(true);
  86 + const [saving, setSaving] = useState(false);
  87 + const [templateTitle, setTemplateTitle] = useState('');
  88 + /** 详情接口完整模板,保存时随 templateProductDefaults 一并 PUT(接口 4.4) */
  89 + const [templateDto, setTemplateDto] = useState<LabelTemplateDto | null>(null);
  90 + const [printFields, setPrintFields] = useState<LabelElement[]>([]);
  91 + const [products, setProducts] = useState<ProductDto[]>([]);
  92 + const [types, setTypes] = useState<LabelTypeDto[]>([]);
  93 + const [rows, setRows] = useState<TemplateDataEntryRow[]>([]);
  94 +
  95 + const productOptions = useMemo(
  96 + () =>
  97 + products.map((p) => {
  98 + const label =
  99 + (p.productName ?? p.productCode ?? '').trim() || p.id;
  100 + return { value: p.id, label };
  101 + }),
  102 + [products],
  103 + );
  104 +
  105 + const labelTypeOptions = useMemo(
  106 + () =>
  107 + types.map((t) => {
  108 + const label =
  109 + (t.typeName ?? t.typeCode ?? '').trim() || t.id;
  110 + return { value: t.id, label };
  111 + }),
  112 + [types],
  113 + );
  114 +
  115 + useEffect(() => {
  116 + let cancelled = false;
  117 + (async () => {
  118 + setLoading(true);
  119 + try {
  120 + const [tpl, prodRes, typeRes] = await Promise.all([
  121 + getLabelTemplate(templateCode),
  122 + getProducts({ skipCount: skipCountForPage(1), maxResultCount: 500 }),
  123 + getLabelTypes({ skipCount: skipCountForPage(1), maxResultCount: 500 }),
  124 + ]);
  125 + if (cancelled) return;
  126 + const title =
  127 + (tpl.templateName ?? tpl.name ?? '').trim() ||
  128 + (tpl.templateCode ?? tpl.id ?? '').trim() ||
  129 + templateCode;
  130 + setTemplateTitle(title);
  131 + const elements = sortTemplateElementsForDisplay(
  132 + (tpl.elements ?? []) as LabelElement[],
  133 + ).filter(isDataEntryTableColumnElement);
  134 + setPrintFields(elements);
  135 + setProducts(prodRes.items ?? []);
  136 + setTypes(typeRes.items ?? []);
  137 + setTemplateDto(tpl);
  138 +
  139 + const defaults = tpl.templateProductDefaults ?? [];
  140 + const fromApi =
  141 + defaults.length > 0
  142 + ? [...defaults].sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0))
  143 + : [];
  144 +
  145 + if (fromApi.length > 0) {
  146 + setRows(
  147 + fromApi.map((d) => ({
  148 + id: newRowId(),
  149 + productId: d.productId,
  150 + labelTypeId: d.labelTypeId,
  151 + fieldValues: { ...d.defaultValues },
  152 + })),
  153 + );
  154 + } else {
  155 + setRows([
  156 + {
  157 + id: newRowId(),
  158 + productId: '',
  159 + labelTypeId: '',
  160 + fieldValues: {},
  161 + },
  162 + ]);
  163 + }
  164 + } catch (e: unknown) {
  165 + if (!cancelled) {
  166 + toast.error('Failed to load template or options.', {
  167 + description: e instanceof Error ? e.message : 'Please try again.',
  168 + });
  169 + setTemplateTitle(templateCode);
  170 + setPrintFields([]);
  171 + setRows([]);
  172 + setTemplateDto(null);
  173 + }
  174 + } finally {
  175 + if (!cancelled) setLoading(false);
  176 + }
  177 + })();
  178 + return () => {
  179 + cancelled = true;
  180 + };
  181 + }, [templateCode]);
  182 +
  183 + const addRow = useCallback(() => {
  184 + setRows((prev) => [
  185 + ...prev,
  186 + { id: newRowId(), productId: '', labelTypeId: '', fieldValues: {} },
  187 + ]);
  188 + }, []);
  189 +
  190 + const removeRow = useCallback((id: string) => {
  191 + setRows((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== id)));
  192 + }, []);
  193 +
  194 + const updateRow = useCallback((id: string, patch: Partial<TemplateDataEntryRow>) => {
  195 + setRows((prev) =>
  196 + prev.map((r) => (r.id === id ? { ...r, ...patch } : r)),
  197 + );
  198 + }, []);
  199 +
  200 + const setFieldValue = useCallback(
  201 + (rowId: string, elementId: string, value: string) => {
  202 + setRows((prev) =>
  203 + prev.map((r) => {
  204 + if (r.id !== rowId) return r;
  205 + return {
  206 + ...r,
  207 + fieldValues: { ...r.fieldValues, [elementId]: value },
  208 + };
  209 + }),
  210 + );
  211 + },
  212 + [],
  213 + );
  214 +
  215 + const handleSave = useCallback(async () => {
  216 + if (!templateDto) {
  217 + toast.error('Template not loaded', { description: 'Please reload the page and try again.' });
  218 + return;
  219 + }
  220 + const touched = rows.filter((r) => r.productId.trim() || r.labelTypeId.trim());
  221 + const incomplete = touched.some(
  222 + (r) => !r.productId.trim() || !r.labelTypeId.trim(),
  223 + );
  224 + if (incomplete) {
  225 + toast.error('Product and label type required', {
  226 + description:
  227 + 'Each row that you started must have both Product and Label type selected.',
  228 + });
  229 + return;
  230 + }
  231 +
  232 + const validRows = rows.filter((r) => r.productId.trim() && r.labelTypeId.trim());
  233 + const templateProductDefaults = validRows.map((r, i) => {
  234 + const defaultValues: Record<string, string> = {};
  235 + for (const f of printFields) {
  236 + defaultValues[f.id] = r.fieldValues[f.id] ?? '';
  237 + }
  238 + return {
  239 + productId: r.productId.trim(),
  240 + labelTypeId: r.labelTypeId.trim(),
  241 + defaultValues,
  242 + orderNum: i + 1,
  243 + };
  244 + });
  245 +
  246 + const fullElements = sortTemplateElementsForDisplay(
  247 + (templateDto.elements ?? []) as LabelElement[],
  248 + );
  249 + if (fullElements.length === 0) {
  250 + toast.error('Template has no elements', { description: 'Cannot save this template.' });
  251 + return;
  252 + }
  253 +
  254 + const loc = appliedLocationToEditor(templateDto);
  255 + setSaving(true);
  256 + try {
  257 + const updated = await updateLabelTemplate(templateCode, {
  258 + id: templateDto.id,
  259 + name: (templateDto.name ?? templateDto.templateName ?? '').trim() || templateCode,
  260 + labelType: (templateDto.labelType ?? 'PRICE') as LabelType,
  261 + unit: (templateDto.unit ?? 'inch') as Unit,
  262 + width: Number(templateDto.width ?? 2),
  263 + height: Number(templateDto.height ?? 2),
  264 + appliedLocation: loc,
  265 + showRuler: templateDto.showRuler ?? true,
  266 + showGrid: templateDto.showGrid ?? true,
  267 + state: templateDto.state ?? true,
  268 + elements: labelElementsToApiPayload(fullElements),
  269 + appliedLocationIds: loc === 'ALL' ? [] : (templateDto.appliedLocationIds ?? []),
  270 + templateProductDefaults,
  271 + });
  272 + setTemplateDto(updated);
  273 + toast.success('Saved', {
  274 + description: 'Template product defaults were updated on the server.',
  275 + });
  276 + } catch (e: unknown) {
  277 + toast.error('Save failed', {
  278 + description: e instanceof Error ? e.message : 'Please try again.',
  279 + });
  280 + } finally {
  281 + setSaving(false);
  282 + }
  283 + }, [templateCode, templateDto, rows, printFields]);
  284 +
  285 + return (
  286 + <div className="h-full flex flex-col min-h-0">
  287 + <div className="flex flex-wrap items-center gap-3 pb-4 border-b border-gray-200 shrink-0">
  288 + <Button
  289 + type="button"
  290 + variant="outline"
  291 + className="h-10 gap-2"
  292 + onClick={onBack}
  293 + >
  294 + <ArrowLeft className="h-4 w-4" />
  295 + Back
  296 + </Button>
  297 + <div className="flex-1 min-w-[200px]">
  298 + <div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
  299 + Label template
  300 + </div>
  301 + <h2 className="text-lg font-semibold text-gray-900 truncate" title={templateTitle}>
  302 + {templateTitle}
  303 + </h2>
  304 + </div>
  305 + <div className="flex items-center gap-2">
  306 + <Button type="button" variant="outline" className="h-10 gap-1" onClick={addRow}>
  307 + <Plus className="h-4 w-4" />
  308 + Add row
  309 + </Button>
  310 + <Button
  311 + type="button"
  312 + className="h-10 bg-blue-600 hover:bg-blue-700"
  313 + onClick={() => void handleSave()}
  314 + disabled={saving || loading || !templateDto}
  315 + >
  316 + {saving ? 'Saving…' : 'Save'}
  317 + </Button>
  318 + </div>
  319 + </div>
  320 +
  321 + <p className="text-sm text-gray-600 py-3 shrink-0">
  322 + Bind product and label type per row. Values are saved with the template (edit API) as{' '}
  323 + <span className="font-medium">templateProductDefaults</span> (interface doc section 4.4). Only{' '}
  324 + <span className="font-medium">FIXED</span> fields appear here.{' '}
  325 + <span className="font-medium">AUTO_DB</span> and <span className="font-medium">PRINT_INPUT</span>{' '}
  326 + are resolved at print time in the app. Column headers use{' '}
  327 + <span className="font-medium">elementName</span>.
  328 + </p>
  329 +
  330 + <div className="flex-1 min-h-0 overflow-auto rounded-md border bg-white shadow-sm">
  331 + {loading ? (
  332 + <div className="p-10 text-center text-sm text-gray-500">Loading…</div>
  333 + ) : printFields.length === 0 ? (
  334 + <div className="p-10 text-center text-sm text-gray-600">
  335 + No <span className="font-medium">FIXED</span> elements in this template (or none besides
  336 + blanks). AUTO_DB / PRINT_INPUT columns are hidden here by design.
  337 + </div>
  338 + ) : (
  339 + <Table>
  340 + <TableHeader>
  341 + <TableRow className="bg-gray-50 hover:bg-gray-50">
  342 + <TableHead className="font-bold text-gray-900 w-[200px] min-w-[160px]">
  343 + Product
  344 + </TableHead>
  345 + <TableHead className="font-bold text-gray-900 w-[180px] min-w-[140px]">
  346 + Label type
  347 + </TableHead>
  348 + {printFields.map((f) => (
  349 + <TableHead
  350 + key={f.id}
  351 + className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap"
  352 + title={f.id}
  353 + >
  354 + {dataEntryColumnLabel(f)}
  355 + </TableHead>
  356 + ))}
  357 + <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead>
  358 + </TableRow>
  359 + </TableHeader>
  360 + <TableBody>
  361 + {rows.map((row) => (
  362 + <TableRow key={row.id} className="hover:bg-gray-50">
  363 + <TableCell className="align-top py-2">
  364 + <SearchableSelect
  365 + value={row.productId}
  366 + onValueChange={(v) => updateRow(row.id, { productId: v })}
  367 + options={productOptions}
  368 + placeholder="Select product"
  369 + searchPlaceholder="Search product…"
  370 + />
  371 + </TableCell>
  372 + <TableCell className="align-top py-2">
  373 + <SearchableSelect
  374 + value={row.labelTypeId}
  375 + onValueChange={(v) => updateRow(row.id, { labelTypeId: v })}
  376 + options={labelTypeOptions}
  377 + placeholder="Select label type"
  378 + searchPlaceholder="Search type…"
  379 + />
  380 + </TableCell>
  381 + {printFields.map((f) => (
  382 + <TableCell key={f.id} className="align-top py-2">
  383 + <DataEntryValueCell
  384 + elementType={f.type}
  385 + value={row.fieldValues[f.id] ?? ''}
  386 + onValueChange={(v) => setFieldValue(row.id, f.id, v)}
  387 + />
  388 + </TableCell>
  389 + ))}
  390 + <TableCell className="text-center align-top py-2">
  391 + <Button
  392 + type="button"
  393 + variant="ghost"
  394 + size="icon"
  395 + className="h-9 w-9 text-red-600 hover:text-red-700 hover:bg-red-50"
  396 + aria-label="Remove row"
  397 + onClick={() => removeRow(row.id)}
  398 + disabled={rows.length <= 1}
  399 + >
  400 + <Trash2 className="h-4 w-4" />
  401 + </Button>
  402 + </TableCell>
  403 + </TableRow>
  404 + ))}
  405 + </TableBody>
  406 + </Table>
  407 + )}
  408 + </div>
  409 + </div>
  410 + );
  411 +}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/ElementsPanel.tsx
1 1 import React from 'react';
2 2 import { ScrollArea } from '../../ui/scroll-area';
3   -import type { ElementType } from '../../../types/labelTemplate';
  3 +import type { ElementLibraryCategory, ElementType } from '../../../types/labelTemplate';
4 4  
5 5 /** 左侧标签库:四类分组(与产品图一致);打印时输入项可带 config 以正确显示为 input */
6 6 const ELEMENT_CATEGORIES: {
7   - title: string;
  7 + title: ElementLibraryCategory;
8 8 subtitle?: string;
9 9 items: { label: string; type: ElementType; config?: Record<string, unknown> }[];
10 10 }[] = [
... ... @@ -76,7 +76,12 @@ const ELEMENT_CATEGORIES: {
76 76 ];
77 77  
78 78 interface ElementsPanelProps {
79   - onAddElement: (type: ElementType, configOverride?: Partial<Record<string, unknown>>) => void;
  79 + onAddElement: (
  80 + type: ElementType,
  81 + configOverride: Partial<Record<string, unknown>> | undefined,
  82 + libraryCategory: ElementLibraryCategory,
  83 + paletteItemLabel: string,
  84 + ) => void;
80 85 }
81 86  
82 87 export function ElementsPanel({ onAddElement }: ElementsPanelProps) {
... ... @@ -102,7 +107,9 @@ export function ElementsPanel({ onAddElement }: ElementsPanelProps) {
102 107 <button
103 108 key={`${cat.title}-${item.label}-${i}`}
104 109 type="button"
105   - onClick={() => onAddElement(item.type, item.config)}
  110 + onClick={() =>
  111 + onAddElement(item.type, item.config, cat.title, item.label)
  112 + }
106 113 className="text-left px-2 py-1 text-xs rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 truncate"
107 114 >
108 115 {item.label}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
... ... @@ -2,6 +2,7 @@ import React, { useCallback, useRef, useEffect } from &#39;react&#39;;
2 2 import JsBarcode from 'jsbarcode';
3 3 import { QRCodeSVG } from 'qrcode.react';
4 4 import type { LabelTemplate, LabelElement, ElementType } from '../../../types/labelTemplate';
  5 +import { isPrintInputElement } from '../../../types/labelTemplate';
5 6 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate';
6 7 import { cn } from '../../ui/utils';
7 8 import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService';
... ... @@ -88,8 +89,30 @@ function pxToUnit(px: number, unit: &#39;cm&#39; | &#39;inch&#39;): number {
88 89 return unit === 'cm' ? px / 37.8 : px / 96;
89 90 }
90 91  
  92 +/**
  93 + * 多选项在画布上的文案:有 prefix 时与 App 打印一致(prefix + 答案/占位);否则在已选字典时显示「字典名称: 内容」。
  94 + */
  95 +function formatMultipleOptionsCanvasLine(
  96 + cfg: Record<string, unknown>,
  97 + text: string,
  98 + selected: string[],
  99 +): string {
  100 + const prefix = String(cfg.prefix ?? '').trim();
  101 + const dictLabel = String(cfg.multipleOptionName ?? cfg.MultipleOptionName ?? '').trim();
  102 + const answers = selected.filter(Boolean).join(', ');
  103 + const fallback = text || '…';
  104 + if (prefix) {
  105 + return answers ? `${prefix}${answers}` : `${prefix}${fallback}`;
  106 + }
  107 + if (dictLabel) {
  108 + const body = answers || fallback;
  109 + return `${dictLabel}: ${body}`;
  110 + }
  111 + return answers || fallback;
  112 +}
  113 +
91 114 /** 根据元素类型与 config 渲染画布上的默认内容 */
92   -function ElementContent({ el }: { el: LabelElement }) {
  115 +function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintField?: boolean }) {
93 116 const cfg = el.config as Record<string, unknown>;
94 117 const type = el.type as ElementType;
95 118  
... ... @@ -106,6 +129,34 @@ function ElementContent({ el }: { el: LabelElement }) {
106 129 const inputType = cfg?.inputType as string | undefined;
107 130 if (type === 'TEXT_STATIC') {
108 131 const text = (cfg?.text as string) ?? '文本';
  132 + if (isAppPrintField) {
  133 + if (inputType === 'options') {
  134 + const selected = Array.isArray(cfg?.selectedOptionValues)
  135 + ? (cfg.selectedOptionValues as string[])
  136 + : [];
  137 + const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
  138 + return (
  139 + <div
  140 + className="w-full h-full px-1 flex flex-col justify-center overflow-hidden pointer-events-none text-gray-600 italic text-[11px] leading-tight break-all"
  141 + style={commonStyle}
  142 + title="Filled in mobile app when printing"
  143 + >
  144 + {line}
  145 + </div>
  146 + );
  147 + }
  148 + const display =
  149 + inputType === 'number' ? ((cfg?.text as string) ?? '0') : text;
  150 + return (
  151 + <div
  152 + className="w-full h-full px-1 flex items-center overflow-hidden pointer-events-none text-gray-600 italic text-[11px]"
  153 + style={commonStyle}
  154 + title="Filled in mobile app when printing"
  155 + >
  156 + {display}
  157 + </div>
  158 + );
  159 + }
109 160 if (inputType === 'number') {
110 161 return (
111 162 <input
... ... @@ -122,28 +173,18 @@ function ElementContent({ el }: { el: LabelElement }) {
122 173 const selected = Array.isArray(cfg?.selectedOptionValues)
123 174 ? (cfg.selectedOptionValues as string[])
124 175 : [];
125   - const prefix = (cfg?.prefix as string) ?? '';
126   - if (selected.length > 0) {
127   - const answers = selected.join(', ');
128   - const line = prefix ? `${prefix}${answers}` : answers;
129   - return (
130   - <div
131   - className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight"
132   - style={commonStyle}
133   - title={line}
134   - >
135   - {line}
136   - </div>
137   - );
138   - }
139   - const placeholder = text || '…';
  176 + const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
  177 + const muted = selected.length === 0;
140 178 return (
141 179 <div
142   - className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight text-gray-400"
  180 + className={cn(
  181 + 'w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight',
  182 + muted && 'text-gray-400',
  183 + )}
143 184 style={commonStyle}
144   - title={placeholder}
  185 + title={line}
145 186 >
146   - {placeholder}
  187 + {line}
147 188 </div>
148 189 );
149 190 }
... ... @@ -235,10 +276,27 @@ function ElementContent({ el }: { el: LabelElement }) {
235 276  
236 277 // 日期/时间
237 278 if (type === 'DATE') {
238   - const format = (cfg?.format as string) ?? 'YYYY-MM-DD';
  279 + const format =
  280 + (typeof cfg?.format === 'string' && cfg.format.trim()
  281 + ? cfg.format
  282 + : typeof cfg?.Format === 'string' && cfg.Format.trim()
  283 + ? cfg.Format
  284 + : 'YYYY-MM-DD') ?? 'YYYY-MM-DD';
239 285 const example = format.replace('YYYY', '2025').replace('MM', '02').replace('DD', '01');
240   - const isInput = cfg?.inputType === 'datetime' || cfg?.inputType === 'date';
  286 + const it = String(cfg?.inputType ?? cfg?.InputType ?? '').toLowerCase();
  287 + const isInput = it === 'datetime' || it === 'date';
241 288 if (isInput) {
  289 + if (isAppPrintField) {
  290 + return (
  291 + <div
  292 + className="w-full h-full px-1 flex items-center justify-center overflow-hidden pointer-events-none text-[10px] text-center whitespace-nowrap"
  293 + style={commonStyle}
  294 + title={`Format: ${format}`}
  295 + >
  296 + {format}
  297 + </div>
  298 + );
  299 + }
242 300 return (
243 301 <input
244 302 type="date"
... ... @@ -254,7 +312,12 @@ function ElementContent({ el }: { el: LabelElement }) {
254 312  
255 313 // (Simplified other types similarly for brevity, ensuring style prop is passed)
256 314 if (type === 'TIME') {
257   - const format = (cfg?.format as string) ?? 'HH:mm';
  315 + const format =
  316 + (typeof cfg?.format === 'string' && cfg.format.trim()
  317 + ? cfg.format
  318 + : typeof cfg?.Format === 'string' && cfg.Format.trim()
  319 + ? cfg.Format
  320 + : 'HH:mm') ?? 'HH:mm';
258 321 const example = format.replace('HH', '12').replace('mm', '30');
259 322 return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>{example}</div>;
260 323 }
... ... @@ -264,9 +327,26 @@ function ElementContent({ el }: { el: LabelElement }) {
264 327 }
265 328  
266 329 if (type === 'WEIGHT') {
267   - const value = (cfg?.value as number) ?? 500;
268   - const unit = (cfg?.unit as string) ?? 'g';
269   - return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>{value}{unit}</div>;
  330 + const rawV = cfg?.value ?? cfg?.Value;
  331 + const numVal =
  332 + rawV == null || rawV === ''
  333 + ? 500
  334 + : typeof rawV === 'number'
  335 + ? rawV
  336 + : Number(rawV);
  337 + const weightNum = Number.isFinite(numVal) ? numVal : 500;
  338 + const weightUnit =
  339 + (typeof cfg?.unit === 'string' && cfg.unit.trim()
  340 + ? cfg.unit
  341 + : typeof cfg?.Unit === 'string' && cfg.Unit.trim()
  342 + ? cfg.Unit
  343 + : 'g') ?? 'g';
  344 + return (
  345 + <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>
  346 + {weightNum}
  347 + {weightUnit}
  348 + </div>
  349 + );
270 350 }
271 351  
272 352 if (type === 'WEIGHT_PRICE') {
... ... @@ -853,7 +933,9 @@ export function LabelCanvas({
853 933
854 934 </div>
855 935 )}
856   - {template.elements.map((el) => (
  936 + {template.elements.map((el) => {
  937 + const isPrintField = isPrintInputElement(el);
  938 + return (
857 939 <div
858 940 key={el.id}
859 941 id={`element-${el.id}`}
... ... @@ -875,7 +957,15 @@ export function LabelCanvas({
875 957 }}
876 958 onPointerDown={(e) => handlePointerDown(e, el.id)}
877 959 >
878   - <ElementContent el={el} />
  960 + <div
  961 + className={cn(
  962 + 'w-full h-full min-h-0 relative',
  963 + isPrintField &&
  964 + 'rounded-sm border-2 border-dashed border-amber-500/85 bg-amber-50/35',
  965 + )}
  966 + >
  967 + <ElementContent el={el} isAppPrintField={isPrintField} />
  968 + </div>
879 969 {selectedId === el.id && (
880 970 <>
881 971 {/* 4 Corners */}
... ... @@ -948,7 +1038,8 @@ export function LabelCanvas({
948 1038 </>
949 1039 )}
950 1040 </div>
951   - ))}
  1041 + );
  1042 + })}
952 1043 </div>
953 1044 </div>
954 1045 </div>
... ... @@ -985,7 +1076,9 @@ export function LabelPreviewOnly({
985 1076 transformOrigin: '0 0',
986 1077 }}
987 1078 >
988   - {template.elements.map((el) => (
  1079 + {template.elements.map((el) => {
  1080 + const isPrintField = isPrintInputElement(el);
  1081 + return (
989 1082 <div
990 1083 key={el.id}
991 1084 className="absolute box-border overflow-hidden pointer-events-none flex items-center justify-center text-xs"
... ... @@ -997,9 +1090,18 @@ export function LabelPreviewOnly({
997 1090 border: el.border === 'line' ? '1px solid #999' : el.border === 'dotted' ? '1px dotted #999' : undefined,
998 1091 }}
999 1092 >
1000   - <ElementContent el={el} />
  1093 + <div
  1094 + className={cn(
  1095 + 'w-full h-full min-h-0 relative',
  1096 + isPrintField &&
  1097 + 'rounded-sm border-2 border-dashed border-amber-500/85 bg-amber-50/35',
  1098 + )}
  1099 + >
  1100 + <ElementContent el={el} isAppPrintField={isPrintField} />
  1101 + </div>
1001 1102 </div>
1002   - ))}
  1103 + );
  1104 + })}
1003 1105 </div>
1004 1106 </div>
1005 1107 </div>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
... ... @@ -24,7 +24,6 @@ import type { LocationDto } from &#39;../../../types/location&#39;;
24 24 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption';
25 25 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
26 26 import { Checkbox } from '../../ui/checkbox';
27   -import { ImageUrlUpload } from '../../ui/image-url-upload';
28 27 import { Trash2 } from 'lucide-react';
29 28  
30 29 interface PropertiesPanelProps {
... ... @@ -147,6 +146,22 @@ export function PropertiesPanel({
147 146 </SelectContent>
148 147 </Select>
149 148 </div>
  149 + <div>
  150 + <Label className="text-xs">Element name</Label>
  151 + <Input
  152 + value={(selectedElement.elementName ?? "").trim()}
  153 + onChange={(e) =>
  154 + onElementChange(selectedElement.id, {
  155 + elementName: e.target.value,
  156 + })
  157 + }
  158 + className="h-8 text-sm mt-1"
  159 + placeholder="e.g. text1"
  160 + />
  161 + <p className="text-[10px] text-gray-400 mt-1">
  162 + Required for save; used as data-entry column header (elementName).
  163 + </p>
  164 + </div>
150 165 <ElementConfigFields
151 166 element={selectedElement}
152 167 onChange={(config) =>
... ... @@ -340,7 +355,7 @@ function MultipleOptionsDictionaryFields({
340 355 useEffect(() => {
341 356 let cancelled = false;
342 357 setLoading(true);
343   - getLabelMultipleOptions({ skipCount: 0, maxResultCount: 500 })
  358 + getLabelMultipleOptions({ skipCount: 1, maxResultCount: 500 })
344 359 .then((res) => {
345 360 if (!cancelled) setRows(res.items ?? []);
346 361 })
... ... @@ -362,6 +377,18 @@ function MultipleOptionsDictionaryFields({
362 377 const active = rows.find((r) => r.id === selectedId);
363 378 const valueList = active?.optionValuesJson ?? [];
364 379  
  380 + /** 从服务端拉到的字典列表就绪后,为已绑定 id 的旧模板补上 multipleOptionName,画布才能显示「名称:」前缀 */
  381 + useEffect(() => {
  382 + if (!selectedId || rows.length === 0) return;
  383 + const row = rows.find((r) => r.id === selectedId);
  384 + const name = String(row?.optionName ?? '').trim();
  385 + if (!row || !name) return;
  386 + const current = String(cfg.multipleOptionName ?? '').trim();
  387 + if (name !== current) {
  388 + onPatch({ multipleOptionName: name });
  389 + }
  390 + }, [selectedId, rows, cfg.multipleOptionName, onPatch]);
  391 +
365 392 const selectValue = selectedId ? selectedId : MULTIPLE_OPTION_NONE;
366 393  
367 394 return (
... ... @@ -372,13 +399,18 @@ function MultipleOptionsDictionaryFields({
372 399 value={selectValue}
373 400 onValueChange={(id) => {
374 401 if (id === MULTIPLE_OPTION_NONE) {
375   - onPatch({ multipleOptionId: '', selectedOptionValues: [] });
  402 + onPatch({ multipleOptionId: '', multipleOptionName: '', selectedOptionValues: [] });
376 403 return;
377 404 }
378 405 const next = rows.find((r) => r.id === id);
379 406 const allowed = new Set(next?.optionValuesJson ?? []);
380 407 const filtered = selectedVals.filter((v) => allowed.has(v));
381   - onPatch({ multipleOptionId: id, selectedOptionValues: filtered });
  408 + const optName = String(next?.optionName ?? next?.optionCode ?? '').trim();
  409 + onPatch({
  410 + multipleOptionId: id,
  411 + multipleOptionName: optName,
  412 + selectedOptionValues: filtered,
  413 + });
382 414 }}
383 415 disabled={loading}
384 416 >
... ... @@ -484,6 +516,25 @@ function TextStaticStyleFields({
484 516 );
485 517 }
486 518  
  519 +/** 读 config(兼容后端 PascalCase、数字以字符串下发) */
  520 +function cfgPickStr(cfg: Record<string, unknown>, keys: string[], fallback: string): string {
  521 + for (const k of keys) {
  522 + const v = cfg[k];
  523 + if (v != null && String(v).trim() !== '') return String(v).trim();
  524 + }
  525 + return fallback;
  526 +}
  527 +
  528 +function cfgPickNum(cfg: Record<string, unknown>, keys: string[], fallback: number): number {
  529 + for (const k of keys) {
  530 + const v = cfg[k];
  531 + if (v == null || v === '') continue;
  532 + const n = typeof v === 'number' ? v : Number(v);
  533 + if (Number.isFinite(n)) return n;
  534 + }
  535 + return fallback;
  536 +}
  537 +
487 538 function ElementConfigFields({
488 539 element,
489 540 onChange,
... ... @@ -559,17 +610,13 @@ function ElementConfigFields({
559 610 return (
560 611 <>
561 612 <div>
562   - <Label className="text-xs">Image</Label>
563   - <div className="mt-1">
564   - <ImageUrlUpload
565   - value={(cfg.src as string) ?? ''}
566   - onChange={(url) => update('src', url)}
567   - boxClassName="max-w-[160px]"
568   - uploadSubDir="label-template"
569   - oneImageOnly
570   - hint="Uses POST /api/app/picture/category/upload (subDir: label-template). Max 5 MB."
571   - />
572   - </div>
  613 + <Label className="text-xs">Image URL / path</Label>
  614 + <Input
  615 + value={(cfg.src as string) ?? ''}
  616 + onChange={(e) => update('src', e.target.value)}
  617 + className="h-8 text-sm mt-1"
  618 + placeholder="https://... or /picture/..."
  619 + />
573 620 </div>
574 621 <div>
575 622 <Label className="text-xs">Scale Mode</Label>
... ... @@ -589,35 +636,43 @@ function ElementConfigFields({
589 636 </div>
590 637 </>
591 638 );
592   - case 'DATE':
  639 + case 'DATE': {
  640 + const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase();
  641 + const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date';
593 642 return (
594 643 <>
595 644 <div>
596 645 <Label className="text-xs">Format</Label>
597 646 <Input
598   - value={(cfg.format as string) ?? 'YYYY-MM-DD'}
  647 + value={cfgPickStr(cfg, ['format', 'Format'], 'YYYY-MM-DD')}
599 648 onChange={(e) => update('format', e.target.value)}
600 649 className="h-8 text-sm mt-1"
601 650 placeholder="YYYY-MM-DD"
602 651 />
  652 + {isPrintDate ? (
  653 + <p className="text-[10px] text-gray-400 mt-1">
  654 + Shown as placeholder on the label until the app fills the date at print time.
  655 + </p>
  656 + ) : null}
603 657 </div>
604 658 <div>
605 659 <Label className="text-xs">Offset Days</Label>
606 660 <Input
607 661 type="number"
608   - value={(cfg.offsetDays as number) ?? 0}
  662 + value={cfgPickNum(cfg, ['offsetDays', 'OffsetDays'], 0)}
609 663 onChange={(e) => update('offsetDays', Number(e.target.value) || 0)}
610 664 className="h-8 text-sm mt-1"
611 665 />
612 666 </div>
613 667 </>
614 668 );
  669 + }
615 670 case 'TIME':
616 671 return (
617 672 <div>
618 673 <Label className="text-xs">Format</Label>
619 674 <Input
620   - value={(cfg.format as string) ?? 'HH:mm'}
  675 + value={cfgPickStr(cfg, ['format', 'Format'], 'HH:mm')}
621 676 onChange={(e) => update('format', e.target.value)}
622 677 className="h-8 text-sm mt-1"
623 678 placeholder="HH:mm"
... ... @@ -630,7 +685,7 @@ function ElementConfigFields({
630 685 <div>
631 686 <Label className="text-xs">Format</Label>
632 687 <Input
633   - value={(cfg.format as string) ?? 'YYYY-MM-DD'}
  688 + value={cfgPickStr(cfg, ['format', 'Format'], 'YYYY-MM-DD')}
634 689 onChange={(e) => update('format', e.target.value)}
635 690 className="h-8 text-sm mt-1"
636 691 placeholder="YYYY-MM-DD"
... ... @@ -640,7 +695,7 @@ function ElementConfigFields({
640 695 <Label className="text-xs">Offset Days</Label>
641 696 <Input
642 697 type="number"
643   - value={(cfg.offsetDays as number) ?? 3}
  698 + value={cfgPickNum(cfg, ['offsetDays', 'OffsetDays'], 3)}
644 699 onChange={(e) => update('offsetDays', Number(e.target.value) || 3)}
645 700 className="h-8 text-sm mt-1"
646 701 />
... ... @@ -654,7 +709,7 @@ function ElementConfigFields({
654 709 <Label className="text-xs">Value</Label>
655 710 <Input
656 711 type="number"
657   - value={(cfg.value as number) ?? 500}
  712 + value={cfgPickNum(cfg, ['value', 'Value'], 500)}
658 713 onChange={(e) => update('value', Number(e.target.value) || 0)}
659 714 className="h-8 text-sm mt-1"
660 715 />
... ... @@ -662,7 +717,7 @@ function ElementConfigFields({
662 717 <div>
663 718 <Label className="text-xs">Unit</Label>
664 719 <Select
665   - value={(cfg.unit as string) ?? 'g'}
  720 + value={cfgPickStr(cfg, ['unit', 'Unit'], 'g')}
666 721 onValueChange={(v) => update('unit', v)}
667 722 >
668 723 <SelectTrigger className="h-8 text-sm mt-1">
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
... ... @@ -7,11 +7,15 @@ import {
7 7 DialogHeader,
8 8 DialogTitle,
9 9 } from '../../ui/dialog';
10   -import type { LabelTemplate, LabelElement } from '../../../types/labelTemplate';
  10 +import type { ElementLibraryCategory, LabelTemplate, LabelElement } from '../../../types/labelTemplate';
11 11 import {
  12 + allocateElementName,
  13 + composeLibraryCategoryForPersist,
12 14 createDefaultTemplate,
13 15 createDefaultElement,
14   - defaultValueSourceTypeForElement,
  16 + labelElementsToApiPayload,
  17 + resolvedLibraryCategoryForPersist,
  18 + valueSourceTypeForLibraryCategory,
15 19 } from '../../../types/labelTemplate';
16 20 import type { LocationDto } from '../../../types/location';
17 21 import { getLocations } from '../../../services/locationService';
... ... @@ -53,7 +57,7 @@ export function LabelTemplateEditor({
53 57 let cancelled = false;
54 58 (async () => {
55 59 try {
56   - const res = await getLocations({ skipCount: 0, maxResultCount: 500 });
  60 + const res = await getLocations({ skipCount: 1, maxResultCount: 500 });
57 61 if (!cancelled) setLocations(res.items ?? []);
58 62 } catch {
59 63 if (!cancelled) setLocations([]);
... ... @@ -68,80 +72,81 @@ export function LabelTemplateEditor({
68 72  
69 73 const addElement = useCallback((
70 74 type: Parameters<typeof createDefaultElement>[0],
71   - configOverride?: Partial<Record<string, unknown>>
  75 + configOverride: Partial<Record<string, unknown>> | undefined,
  76 + libraryCategory: ElementLibraryCategory,
  77 + paletteItemLabel: string,
72 78 ) => {
73   - // 计算画布中心位置(像素)
74   - const unitToPx = (value: number, unit: 'cm' | 'inch'): number => {
75   - return unit === 'cm' ? value * 37.8 : value * 96;
76   - };
77   -
78   - const canvasWidthPx = unitToPx(template.width, template.unit);
79   - const canvasHeightPx = unitToPx(template.height, template.unit);
80   -
81   - // 创建默认元素以获取其尺寸
82   - const tempEl = createDefaultElement(type, 0, 0);
83   -
84   - // 对齐到网格
85   - const GRID_SIZE = 8;
86   - const snapToGrid = (value: number): number => {
87   - return Math.round(value / GRID_SIZE) * GRID_SIZE;
88   - };
89   -
90   - // 计算元素中心对齐到画布中心的位置
91   - let centerX = (canvasWidthPx - tempEl.width) / 2;
92   - let centerY = (canvasHeightPx - tempEl.height) / 2;
93   -
94   - // 检查是否与现有元素重叠,如果重叠则尝试偏移
95   - const checkOverlap = (x: number, y: number, width: number, height: number): boolean => {
96   - return template.elements.some((el) => {
97   - const elRight = el.x + el.width;
98   - const elBottom = el.y + el.height;
99   - const newRight = x + width;
100   - const newBottom = y + height;
101   - return !(x >= elRight || newRight <= el.x || y >= elBottom || newBottom <= el.y);
102   - });
103   - };
104   -
105   - // 如果中心位置重叠,尝试在周围寻找空位
106   - if (checkOverlap(centerX, centerY, tempEl.width, tempEl.height)) {
107   - const offset = GRID_SIZE * 2;
108   - let found = false;
109   - // 尝试右下方
110   - for (let tryY = centerY; tryY < canvasHeightPx - tempEl.height && !found; tryY += offset) {
111   - for (let tryX = centerX; tryX < canvasWidthPx - tempEl.width && !found; tryX += offset) {
112   - if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) {
113   - centerX = tryX;
114   - centerY = tryY;
115   - found = true;
116   - break;
117   - }
118   - }
119   - }
120   - // 如果还没找到,尝试左上方
121   - if (!found) {
122   - for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) {
123   - for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) {
124   - if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) {
  79 + let addedId = "";
  80 + setTemplate((prev) => {
  81 + const unitToPx = (value: number, unit: "cm" | "inch"): number =>
  82 + unit === "cm" ? value * 37.8 : value * 96;
  83 +
  84 + const canvasWidthPx = unitToPx(prev.width, prev.unit);
  85 + const canvasHeightPx = unitToPx(prev.height, prev.unit);
  86 +
  87 + let el = createDefaultElement(type, 0, 0);
  88 + const GRID_SIZE = 8;
  89 + const snapToGrid = (value: number): number =>
  90 + Math.round(value / GRID_SIZE) * GRID_SIZE;
  91 +
  92 + let centerX = (canvasWidthPx - el.width) / 2;
  93 + let centerY = (canvasHeightPx - el.height) / 2;
  94 +
  95 + const checkOverlap = (x: number, y: number, width: number, height: number): boolean =>
  96 + prev.elements.some((o) => {
  97 + const elRight = o.x + o.width;
  98 + const elBottom = o.y + o.height;
  99 + const newRight = x + width;
  100 + const newBottom = y + height;
  101 + return !(x >= elRight || newRight <= o.x || y >= elBottom || newBottom <= o.y);
  102 + });
  103 +
  104 + if (checkOverlap(centerX, centerY, el.width, el.height)) {
  105 + const offset = GRID_SIZE * 2;
  106 + let found = false;
  107 + for (let tryY = centerY; tryY < canvasHeightPx - el.height && !found; tryY += offset) {
  108 + for (let tryX = centerX; tryX < canvasWidthPx - el.width && !found; tryX += offset) {
  109 + if (!checkOverlap(tryX, tryY, el.width, el.height)) {
125 110 centerX = tryX;
126 111 centerY = tryY;
127 112 found = true;
128   - break;
  113 + }
  114 + }
  115 + }
  116 + if (!found) {
  117 + for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) {
  118 + for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) {
  119 + if (!checkOverlap(tryX, tryY, el.width, el.height)) {
  120 + centerX = tryX;
  121 + centerY = tryY;
  122 + found = true;
  123 + }
129 124 }
130 125 }
131 126 }
132 127 }
133   - }
134   -
135   - const el = createDefaultElement(type, Math.max(0, snapToGrid(centerX)), Math.max(0, snapToGrid(centerY)));
136   - if (configOverride && Object.keys(configOverride).length > 0) {
137   - el.config = { ...el.config, ...configOverride };
138   - }
139   - setTemplate((prev) => ({
140   - ...prev,
141   - elements: [...prev.elements, el],
142   - }));
143   - setSelectedId(el.id);
144   - }, [template.width, template.height, template.unit, template.elements]);
  128 +
  129 + el = {
  130 + ...el,
  131 + x: Math.max(0, snapToGrid(centerX)),
  132 + y: Math.max(0, snapToGrid(centerY)),
  133 + };
  134 + if (configOverride && Object.keys(configOverride).length > 0) {
  135 + el.config = { ...el.config, ...configOverride };
  136 + }
  137 + const elementName = allocateElementName(paletteItemLabel, prev.elements);
  138 + const vst = valueSourceTypeForLibraryCategory(libraryCategory);
  139 + el = {
  140 + ...el,
  141 + libraryCategory: composeLibraryCategoryForPersist(libraryCategory, paletteItemLabel),
  142 + valueSourceType: vst,
  143 + elementName,
  144 + };
  145 + addedId = el.id;
  146 + return { ...prev, elements: [...prev.elements, el] };
  147 + });
  148 + setSelectedId(addedId);
  149 + }, [template.width, template.height, template.unit]);
145 150  
146 151 const updateElement = useCallback((id: string, patch: Partial<LabelElement>) => {
147 152 setTemplate((prev) => ({
... ... @@ -180,6 +185,31 @@ export function LabelTemplateEditor({
180 185 return;
181 186 }
182 187  
  188 + const emptyName = template.elements.find(
  189 + (el) => !(el.elementName ?? "").trim(),
  190 + );
  191 + if (emptyName) {
  192 + toast.error("Component name required.", {
  193 + description: "Each element must have a non-empty elementName (组件名字不能为空).",
  194 + });
  195 + return;
  196 + }
  197 +
  198 + const optionsWithoutDictionary = template.elements.find((el) => {
  199 + if (el.type !== "TEXT_STATIC") return false;
  200 + const cfg = el.config as Record<string, unknown>;
  201 + if (String(cfg?.inputType ?? "").toLowerCase() !== "options") return false;
  202 + const mid = String(cfg?.multipleOptionId ?? cfg?.MultipleOptionId ?? "").trim();
  203 + return !mid;
  204 + });
  205 + if (optionsWithoutDictionary) {
  206 + toast.error("Option dictionary required.", {
  207 + description:
  208 + "Each Multiple Options element must have an Option dictionary selected in the properties panel.",
  209 + });
  210 + return;
  211 + }
  212 +
183 213 // 转换 LabelTemplate 到 API 需要的格式(对齐 LabelTemplateCreateInputVo)
184 214 const apiInput = {
185 215 id: code,
... ... @@ -192,21 +222,7 @@ export function LabelTemplateEditor({
192 222 showRuler: template.showRuler,
193 223 showGrid: template.showGrid ?? true,
194 224 state: true,
195   - elements: template.elements.map((el, index) => ({
196   - id: el.id,
197   - type: el.type,
198   - x: el.x,
199   - y: el.y,
200   - width: el.width,
201   - height: el.height,
202   - rotation: el.rotation,
203   - border: el.border,
204   - zIndex: el.zIndex ?? index + 1,
205   - orderNum: el.orderNum ?? index + 1,
206   - valueSourceType: el.valueSourceType ?? defaultValueSourceTypeForElement(el.type),
207   - isRequiredInput: el.isRequiredInput ?? false,
208   - config: el.config,
209   - })),
  225 + elements: labelElementsToApiPayload(template.elements),
210 226 appliedLocationIds:
211 227 template.appliedLocation === "ALL" ? [] : (template.appliedLocationIds ?? []),
212 228 };
... ... @@ -234,7 +250,16 @@ export function LabelTemplateEditor({
234 250 }, [template, templateId, onSaved, onClose]);
235 251  
236 252 const handleExport = useCallback(() => {
237   - const blob = new Blob([JSON.stringify(template, null, 2)], {
  253 + const payload: LabelTemplate = {
  254 + ...template,
  255 + elements: template.elements.map((el) => ({
  256 + ...el,
  257 + elementName: (el.elementName ?? "").trim(),
  258 + valueSourceType: resolvedValueSourceTypeForSave(el),
  259 + libraryCategory: resolvedLibraryCategoryForPersist(el),
  260 + })),
  261 + };
  262 + const blob = new Blob([JSON.stringify(payload, null, 2)], {
238 263 type: 'application/json',
239 264 });
240 265 const url = URL.createObjectURL(blob);
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
... ... @@ -24,8 +24,9 @@ import {
24 24 DialogHeader,
25 25 DialogTitle,
26 26 } from '../ui/dialog';
27   -import { Plus, Pencil, MoreHorizontal, Trash2 } from 'lucide-react';
  27 +import { Plus, Pencil, MoreHorizontal, Trash2, ClipboardList } from 'lucide-react';
28 28 import { toast } from 'sonner';
  29 +import { skipCountForPage } from '../../lib/paginationQuery';
29 30 import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
30 31 import {
31 32 Pagination,
... ... @@ -39,7 +40,8 @@ import { getLabelTemplates, getLabelTemplate, deleteLabelTemplate } from &#39;../../
39 40 import { getLocations } from '../../services/locationService';
40 41 import { appliedLocationToEditor, type LabelTemplateDto } from '../../types/labelTemplate';
41 42 import { LabelTemplateEditor } from './LabelTemplateEditor';
42   -import type { LabelTemplate } from '../../types/labelTemplate';
  43 +import { LabelTemplateDataEntryView } from './LabelTemplateDataEntryView';
  44 +import type { LabelElement, LabelTemplate } from '../../types/labelTemplate';
43 45 import type { LocationDto } from '../../types/location';
44 46  
45 47 function toDisplay(v: string | null | undefined): string {
... ... @@ -97,8 +99,9 @@ function templateListDisplaySize(t: LabelTemplateDto): string {
97 99  
98 100 export function LabelTemplatesView() {
99 101 const [templates, setTemplates] = useState<LabelTemplateDto[]>([]);
100   - const [viewMode, setViewMode] = useState<'list' | 'editor'>('list');
  102 + const [viewMode, setViewMode] = useState<'list' | 'editor' | 'dataEntry'>('list');
101 103 const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
  104 + const [dataEntryTemplateCode, setDataEntryTemplateCode] = useState<string | null>(null);
102 105 const [initialTemplate, setInitialTemplate] = useState<LabelTemplate | null>(null);
103 106 const [loading, setLoading] = useState(false);
104 107 const [total, setTotal] = useState(0);
... ... @@ -134,7 +137,7 @@ export function LabelTemplatesView() {
134 137 let cancelled = false;
135 138 (async () => {
136 139 try {
137   - const res = await getLocations({ skipCount: 0, maxResultCount: 500 });
  140 + const res = await getLocations({ skipCount: 1, maxResultCount: 500 });
138 141 if (!cancelled) setLocations(res.items ?? []);
139 142 } catch {
140 143 if (!cancelled) setLocations([]);
... ... @@ -159,7 +162,7 @@ export function LabelTemplatesView() {
159 162  
160 163 setLoading(true);
161 164 try {
162   - const skipCount = (pageIndex - 1) * pageSize;
  165 + const skipCount = skipCountForPage(pageIndex);
163 166 const res = await getLabelTemplates(
164 167 {
165 168 skipCount,
... ... @@ -215,7 +218,14 @@ export function LabelTemplatesView() {
215 218 appliedLocationIds: [...(apiTemplate.appliedLocationIds ?? [])],
216 219 showRuler: apiTemplate.showRuler ?? true,
217 220 showGrid: apiTemplate.showGrid ?? true,
218   - elements: (apiTemplate.elements ?? []) as LabelTemplate['elements'],
  221 + elements: (apiTemplate.elements ?? []).map((raw, idx) => {
  222 + const el = raw as LabelElement;
  223 + const en = (el.elementName ?? "").trim();
  224 + return {
  225 + ...el,
  226 + elementName: en || `element${idx + 1}`,
  227 + };
  228 + }),
219 229 };
220 230 setInitialTemplate(editorTemplate);
221 231 setViewMode('editor');
... ... @@ -234,6 +244,17 @@ export function LabelTemplatesView() {
234 244 setInitialTemplate(null);
235 245 };
236 246  
  247 + const handleOpenDataEntry = (templateCode: string) => {
  248 + setActionsOpenForId(null);
  249 + setDataEntryTemplateCode(templateCode);
  250 + setViewMode('dataEntry');
  251 + };
  252 +
  253 + const handleCloseDataEntry = () => {
  254 + setViewMode('list');
  255 + setDataEntryTemplateCode(null);
  256 + };
  257 +
237 258 const openDelete = (template: LabelTemplateDto) => {
238 259 setActionsOpenForId(null);
239 260 setDeletingTemplate(template);
... ... @@ -253,6 +274,17 @@ export function LabelTemplatesView() {
253 274 );
254 275 }
255 276  
  277 + if (viewMode === 'dataEntry' && dataEntryTemplateCode) {
  278 + return (
  279 + <div className="h-[calc(100vh-8rem)] min-h-[500px] flex flex-col pt-2">
  280 + <LabelTemplateDataEntryView
  281 + templateCode={dataEntryTemplateCode}
  282 + onBack={handleCloseDataEntry}
  283 + />
  284 + </div>
  285 + );
  286 + }
  287 +
256 288 return (
257 289 <div className="h-full flex flex-col">
258 290 <div className="pb-4">
... ... @@ -372,7 +404,17 @@ export function LabelTemplatesView() {
372 404 <MoreHorizontal className="h-4 w-4 text-gray-500" />
373 405 </Button>
374 406 </PopoverTrigger>
375   - <PopoverContent align="end" className="w-40 p-1">
  407 + <PopoverContent align="end" className="w-48 p-1">
  408 + <Button
  409 + type="button"
  410 + variant="ghost"
  411 + className="w-full justify-start gap-2 h-9 px-2 font-normal"
  412 + title="录入数据"
  413 + onClick={() => handleOpenDataEntry(t.id)}
  414 + >
  415 + <ClipboardList className="w-4 h-4" />
  416 + Enter Data
  417 + </Button>
376 418 <Button
377 419 type="button"
378 420 variant="ghost"
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTypesView.tsx
... ... @@ -29,6 +29,7 @@ import { Switch } from &quot;../ui/switch&quot;;
29 29 import { Badge } from "../ui/badge";
30 30 import { Plus, Edit, MoreHorizontal, Trash2 } from "lucide-react";
31 31 import { toast } from "sonner";
  32 +import { skipCountForPage } from "../../lib/paginationQuery";
32 33 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 34 import {
34 35 Pagination,
... ... @@ -100,7 +101,7 @@ export function LabelTypesView() {
100 101  
101 102 setLoading(true);
102 103 try {
103   - const skipCount = (pageIndex - 1) * pageSize;
  104 + const skipCount = skipCountForPage(pageIndex);
104 105 const res = await getLabelTypes(
105 106 {
106 107 skipCount,
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
... ... @@ -29,6 +29,7 @@ import { Switch } from &quot;../ui/switch&quot;;
29 29 import { Badge } from "../ui/badge";
30 30 import { Plus, Edit, MoreHorizontal, ChevronsUpDown, Trash2 } from "lucide-react";
31 31 import { toast } from "sonner";
  32 +import { skipCountForPage } from "../../lib/paginationQuery";
32 33 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 34 import { Checkbox } from "../ui/checkbox";
34 35 import { SearchableSelect } from "../ui/searchable-select";
... ... @@ -140,11 +141,11 @@ function useLabelFormReferenceData(open: boolean) {
140 141 setLoading(true);
141 142 try {
142 143 const [tplRes, locRes, catRes, typeRes, prodRes] = await Promise.all([
143   - getLabelTemplates({ skipCount: 0, maxResultCount: 500 }),
144   - getLocations({ skipCount: 0, maxResultCount: 500 }),
145   - getLabelCategories({ skipCount: 0, maxResultCount: 500 }),
146   - getLabelTypes({ skipCount: 0, maxResultCount: 500 }),
147   - getProducts({ skipCount: 0, maxResultCount: 500 }),
  144 + getLabelTemplates({ skipCount: 1, maxResultCount: 500 }),
  145 + getLocations({ skipCount: 1, maxResultCount: 500 }),
  146 + getLabelCategories({ skipCount: 1, maxResultCount: 500 }),
  147 + getLabelTypes({ skipCount: 1, maxResultCount: 500 }),
  148 + getProducts({ skipCount: 1, maxResultCount: 500 }),
148 149 ]);
149 150 if (cancelled) return;
150 151 setTemplates(tplRes.items ?? []);
... ... @@ -336,7 +337,7 @@ export function LabelsList() {
336 337  
337 338 setLoading(true);
338 339 try {
339   - const skipCount = (pageIndex - 1) * pageSize;
  340 + const skipCount = skipCountForPage(pageIndex);
340 341 const res = await getLabels(
341 342 {
342 343 skipCount,
... ...
美国版/Food Labeling Management Platform/src/components/labels/MultipleOptionsView.tsx
... ... @@ -29,6 +29,7 @@ import { Switch } from &quot;../ui/switch&quot;;
29 29 import { Badge } from "../ui/badge";
30 30 import { Plus, Edit, MoreHorizontal, X, Trash2 } from "lucide-react";
31 31 import { toast } from "sonner";
  32 +import { skipCountForPage } from "../../lib/paginationQuery";
32 33 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 34 import {
34 35 Pagination,
... ... @@ -100,7 +101,7 @@ export function MultipleOptionsView() {
100 101  
101 102 setLoading(true);
102 103 try {
103   - const skipCount = (pageIndex - 1) * pageSize;
  104 + const skipCount = skipCountForPage(pageIndex);
104 105 const res = await getLabelMultipleOptions(
105 106 {
106 107 skipCount,
... ...
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
... ... @@ -29,6 +29,7 @@ import { Label } from &quot;../ui/label&quot;;
29 29 import { Badge } from "../ui/badge";
30 30 import { Switch } from "../ui/switch";
31 31 import { toast } from "sonner";
  32 +import { skipCountForPage } from "../../lib/paginationQuery";
32 33 import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
33 34 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
34 35 import {
... ... @@ -152,8 +153,7 @@ export function LocationsView() {
152 153  
153 154 setLoading(true);
154 155 try {
155   - // 统一约定:SkipCount 传“页码(从1开始)”
156   - const skipCount = Math.max(1, pageIndex);
  156 + const skipCount = skipCountForPage(pageIndex);
157 157 const effectiveKeyword = locationPick !== "all" ? locationPick : debouncedKeyword;
158 158 const res = await getLocations(
159 159 {
... ...
美国版/Food Labeling Management Platform/src/components/menus/MenuManagementView.tsx
1 1 import React, { useEffect, useMemo, useRef, useState } from "react";
2 2 import { Edit, MoreHorizontal, Plus, Trash2 } from "lucide-react";
3 3 import { toast } from "sonner";
  4 +import { skipCountForPage } from "../../lib/paginationQuery";
4 5  
5 6 import { Button } from "../ui/button";
6 7 import { Input } from "../ui/input";
... ... @@ -117,7 +118,7 @@ export function MenuManagementView() {
117 118  
118 119 setLoading(true);
119 120 try {
120   - const skipCount = (pageIndex - 1) * pageSize;
  121 + const skipCount = skipCountForPage(pageIndex);
121 122 const res = await getMenus(
122 123 {
123 124 skipCount,
... ...
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
... ... @@ -41,6 +41,7 @@ import { Switch } from &quot;../ui/switch&quot;;
41 41 import { Badge } from "../ui/badge";
42 42 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
43 43 import { toast } from "sonner";
  44 +import { skipCountForPage } from "../../lib/paginationQuery";
44 45 import { getLocations } from "../../services/locationService";
45 46 import {
46 47 createProductCategory,
... ... @@ -84,7 +85,7 @@ function toDisplay(v: string | null | undefined): string {
84 85 async function buildProductLocationMap(signal?: AbortSignal): Promise<Map<string, string[]>> {
85 86 const map = new Map<string, string[]>();
86 87 try {
87   - const res = await getProductLocations({ skipCount: 0, maxResultCount: 2000 }, signal);
  88 + const res = await getProductLocations({ skipCount: 1, maxResultCount: 2000 }, signal);
88 89 for (const row of res.items ?? []) {
89 90 const pid = (row.productId ?? "").trim();
90 91 const lid = (row.locationId ?? "").trim();
... ... @@ -172,9 +173,9 @@ export function ProductsView() {
172 173 (async () => {
173 174 try {
174 175 const [locRes, catRes] = await Promise.all([
175   - getLocations({ skipCount: 0, maxResultCount: 500 }),
  176 + getLocations({ skipCount: 1, maxResultCount: 500 }),
176 177 getProductCategories({
177   - skipCount: 0,
  178 + skipCount: 1,
178 179 maxResultCount: 500,
179 180 sorting: "OrderNum desc",
180 181 }),
... ... @@ -223,7 +224,7 @@ export function ProductsView() {
223 224 if (needClientFilter) {
224 225 const res = await getProducts(
225 226 {
226   - skipCount: 0,
  227 + skipCount: 1,
227 228 maxResultCount: 500,
228 229 keyword: debouncedKeyword || undefined,
229 230 state: stateFilter === "all" ? undefined : stateFilter === "true",
... ... @@ -244,7 +245,7 @@ export function ProductsView() {
244 245 const start = (pageIndex - 1) * pageSize;
245 246 setProducts(list.slice(start, start + pageSize));
246 247 } else {
247   - const skip = (pageIndex - 1) * pageSize;
  248 + const skip = skipCountForPage(pageIndex);
248 249 const res = await getProducts(
249 250 {
250 251 skipCount: skip,
... ... @@ -303,7 +304,7 @@ export function ProductsView() {
303 304  
304 305 setCatLoading(true);
305 306 try {
306   - const skip = (catPageIndex - 1) * catPageSize;
  307 + const skip = skipCountForPage(catPageIndex);
307 308 const res = await getProductCategories(
308 309 {
309 310 skipCount: skip,
... ...
美国版/Food Labeling Management Platform/src/lib/paginationQuery.ts 0 → 100644
  1 +/**
  2 + * 与后端 SqlSugar 分页约定一致:Query 参数 SkipCount 表示「页码」(从 1 起),第一页传 1,不是 0 基 offset。
  3 + */
  4 +export function skipCountForPage(pageIndex1Based: number): number {
  5 + return Math.max(1, pageIndex1Based);
  6 +}
... ...
美国版/Food Labeling Management Platform/src/services/labelTemplateService.ts
1 1 import { createApiClient } from "../lib/apiClient";
2 2 import type {
  3 + LabelElement,
3 4 LabelTemplateCreateInput,
4 5 LabelTemplateDto,
5 6 LabelTemplateGetListInput,
  7 + LabelTemplateProductDefaultDto,
6 8 LabelTemplateUpdateInput,
7 9 PagedResultDto,
8 10 } from "../types/labelTemplate";
... ... @@ -26,6 +28,63 @@ function normalizeTemplateCode(raw: unknown): string {
26 28 return typeof id === "string" ? id.trim() : String(id ?? "").trim();
27 29 }
28 30  
  31 +/** 详情/列表里的 elements 兼容 PascalCase(如 InputKey) */
  32 +function normalizeTemplateElements(list: unknown): LabelElement[] {
  33 + if (!Array.isArray(list)) return [];
  34 + return list.map((raw) => {
  35 + const e = raw as Record<string, unknown> & {
  36 + InputKey?: unknown;
  37 + inputKey?: unknown;
  38 + ElementName?: unknown;
  39 + elementName?: unknown;
  40 + LibraryCategory?: unknown;
  41 + libraryCategory?: unknown;
  42 + };
  43 + const ik = e.inputKey ?? e.InputKey;
  44 + const nameRaw = e.elementName ?? e.ElementName;
  45 + const lcRaw = e.libraryCategory ?? e.LibraryCategory;
  46 + let libraryCategory: LabelElement["libraryCategory"];
  47 + if (typeof lcRaw === "string") {
  48 + const t = lcRaw.trim();
  49 + if (t) libraryCategory = t;
  50 + }
  51 + return {
  52 + ...(e as object),
  53 + elementName:
  54 + typeof nameRaw === "string" ? nameRaw.trim() : undefined,
  55 + inputKey: typeof ik === "string" ? ik : e.inputKey ?? null,
  56 + libraryCategory,
  57 + config: (e.config && typeof e.config === "object" ? e.config : {}) as LabelElement["config"],
  58 + } as LabelElement;
  59 + });
  60 +}
  61 +
  62 +function normalizeDefaultValuesJson(raw: unknown): Record<string, string> {
  63 + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
  64 + const out: Record<string, string> = {};
  65 + for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
  66 + if (v == null) out[k] = "";
  67 + else if (typeof v === "string") out[k] = v;
  68 + else if (typeof v === "number" || typeof v === "boolean") out[k] = String(v);
  69 + else out[k] = JSON.stringify(v);
  70 + }
  71 + return out;
  72 +}
  73 +
  74 +function normalizeTemplateProductDefaultsList(raw: unknown): LabelTemplateProductDefaultDto[] {
  75 + if (!Array.isArray(raw)) return [];
  76 + return raw.map((item, index) => {
  77 + const o = item as Record<string, unknown>;
  78 + const dv = o.defaultValues ?? o.DefaultValues ?? o.defaultValuesJson ?? o.DefaultValuesJson;
  79 + return {
  80 + productId: String(o.productId ?? o.ProductId ?? "").trim(),
  81 + labelTypeId: String(o.labelTypeId ?? o.LabelTypeId ?? "").trim(),
  82 + defaultValues: normalizeDefaultValuesJson(dv),
  83 + orderNum: Number(o.orderNum ?? o.OrderNum ?? index + 1) || index + 1,
  84 + };
  85 + });
  86 +}
  87 +
29 88 function normalizeLabelTemplateDto(raw: unknown): LabelTemplateDto {
30 89 const r = raw as Record<string, unknown>;
31 90 const ids =
... ... @@ -58,7 +117,10 @@ function normalizeLabelTemplateDto(raw: unknown): LabelTemplateDto {
58 117 contentsCount: contentsCountVo ?? (r.contentsCount as number | null),
59 118 lastEdited: (typeof lastEditedVo === "string" ? lastEditedVo : null) ?? (r.lastEdited as string | null),
60 119 appliedLocationIds,
61   - elements: Array.isArray(r.elements) ? (r.elements as LabelTemplateDto["elements"]) : [],
  120 + elements: normalizeTemplateElements(r.elements),
  121 + templateProductDefaults: normalizeTemplateProductDefaultsList(
  122 + r.templateProductDefaults ?? r.TemplateProductDefaults,
  123 + ),
62 124 } as LabelTemplateDto;
63 125 }
64 126  
... ... @@ -113,23 +175,32 @@ export async function createLabelTemplate(input: LabelTemplateCreateInput): Prom
113 175 }
114 176  
115 177 export async function updateLabelTemplate(templateCode: string, input: LabelTemplateUpdateInput): Promise<LabelTemplateDto> {
  178 + const body: Record<string, unknown> = {
  179 + id: input.id,
  180 + name: input.name,
  181 + labelType: input.labelType,
  182 + unit: input.unit,
  183 + width: input.width,
  184 + height: input.height,
  185 + appliedLocation: input.appliedLocation,
  186 + showRuler: input.showRuler ?? true,
  187 + showGrid: input.showGrid ?? true,
  188 + state: input.state ?? true,
  189 + elements: input.elements,
  190 + appliedLocationIds: input.appliedLocationIds ?? [],
  191 + };
  192 + if (input.templateProductDefaults !== undefined) {
  193 + body.templateProductDefaults = input.templateProductDefaults.map((row, i) => ({
  194 + productId: row.productId,
  195 + labelTypeId: row.labelTypeId,
  196 + defaultValues: row.defaultValues,
  197 + orderNum: row.orderNum ?? i + 1,
  198 + }));
  199 + }
116 200 const updated = await api.requestJson<LabelTemplateDto>({
117 201 path: `${PATH}/${encodeURIComponent(templateCode)}`,
118 202 method: "PUT",
119   - body: {
120   - id: input.id,
121   - name: input.name,
122   - labelType: input.labelType,
123   - unit: input.unit,
124   - width: input.width,
125   - height: input.height,
126   - appliedLocation: input.appliedLocation,
127   - showRuler: input.showRuler ?? true,
128   - showGrid: input.showGrid ?? true,
129   - state: input.state ?? true,
130   - elements: input.elements,
131   - appliedLocationIds: input.appliedLocationIds ?? [],
132   - },
  203 + body,
133 204 });
134 205 return normalizeLabelTemplateDto(updated);
135 206 }
... ...
美国版/Food Labeling Management Platform/src/types/labelTemplate.ts
... ... @@ -24,6 +24,132 @@ export type ElementType =
24 24 | 'BLANK'
25 25 | 'NUTRITION';
26 26  
  27 +/** 左侧元素库分组标题(与 Elements 面板四类一致;导出/保存时写入每条 element) */
  28 +export type ElementLibraryCategory =
  29 + | '模版信息'
  30 + | '标签信息'
  31 + | '自动生成'
  32 + | '打印时输入';
  33 +
  34 +export const ELEMENT_LIBRARY_CATEGORIES: readonly ElementLibraryCategory[] = [
  35 + '模版信息',
  36 + '标签信息',
  37 + '自动生成',
  38 + '打印时输入',
  39 +] as const;
  40 +
  41 +export function isElementLibraryCategory(s: string): s is ElementLibraryCategory {
  42 + return (ELEMENT_LIBRARY_CATEGORIES as readonly string[]).includes(s);
  43 +}
  44 +
  45 +/** 左侧面板分组 → 接口 ValueSourceType(模板+标签信息=FIXED;自动生成=AUTO_DB;打印时输入=PRINT_INPUT) */
  46 +export function valueSourceTypeForLibraryCategory(
  47 + group: ElementLibraryCategory,
  48 +): "FIXED" | "AUTO_DB" | "PRINT_INPUT" {
  49 + if (group === "自动生成") return "AUTO_DB";
  50 + if (group === "打印时输入") return "PRINT_INPUT";
  51 + return "FIXED";
  52 +}
  53 +
  54 +function slugPaletteItemLabel(label: string): string {
  55 + const s = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
  56 + return s || "field";
  57 +}
  58 +
  59 +function escapeRegExp(s: string): string {
  60 + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  61 +}
  62 +
  63 +/**
  64 + * 默认 elementName:与面板项同名小写 slug + 序号,如 Text → text1、text2。
  65 + */
  66 +export function allocateElementName(
  67 + paletteItemLabel: string,
  68 + existing: LabelElement[],
  69 +): string {
  70 + const base = slugPaletteItemLabel(paletteItemLabel);
  71 + const re = new RegExp(`^${escapeRegExp(base)}(\\d+)$`, "i");
  72 + const used: number[] = [];
  73 + for (const el of existing) {
  74 + const n = (el.elementName ?? "").trim();
  75 + if (!n) continue;
  76 + const m = n.match(re);
  77 + if (m) used.push(parseInt(m[1], 10));
  78 + if (n.toLowerCase() === base) used.push(1);
  79 + }
  80 + const next = used.length > 0 ? Math.max(...used) + 1 : 1;
  81 + return `${base}${next}`;
  82 +}
  83 +
  84 +/** 四类分组 → 导出/保存用的英文前缀(与 JSON 中 libraryCategory 第一段一致) */
  85 +export const LIBRARY_CATEGORY_ENGLISH_PREFIX: Record<ElementLibraryCategory, string> = {
  86 + 模版信息: "template",
  87 + 标签信息: "label",
  88 + 自动生成: "auto",
  89 + 打印时输入: "print",
  90 +};
  91 +
  92 +/** 形如 `print_Multiple Options`、`template_Text` */
  93 +export function composeLibraryCategoryForPersist(
  94 + group: ElementLibraryCategory,
  95 + paletteFieldName: string,
  96 +): string {
  97 + const prefix = LIBRARY_CATEGORY_ENGLISH_PREFIX[group];
  98 + const name = (paletteFieldName ?? "").trim() || "Field";
  99 + return `${prefix}_${name}`;
  100 +}
  101 +
  102 +const COMPOSED_LIBRARY_CATEGORY_RE = /^(template|label|auto|print)_/;
  103 +
  104 +export function isComposedLibraryCategoryValue(s: string): boolean {
  105 + return COMPOSED_LIBRARY_CATEGORY_RE.test(s.trim());
  106 +}
  107 +
  108 +/**
  109 + * 旧模板无「面板英文名」时的兜底(同 type 多入口时可能不准,新模板以点击面板为准)。
  110 + */
  111 +export function inferPaletteEnglishLabel(el: LabelElement): string {
  112 + const cfg = (el.config ?? {}) as Record<string, unknown>;
  113 + if (isPrintInputElement(el)) {
  114 + if (el.type === "TEXT_STATIC") {
  115 + const it = cfg.inputType as string | undefined;
  116 + if (it === "number") return "Number";
  117 + if (it === "options") return "Multiple Options";
  118 + return "Text";
  119 + }
  120 + if (el.type === "DATE") return "Date & Time";
  121 + if (el.type === "WEIGHT") return "Weight";
  122 + }
  123 + switch (el.type) {
  124 + case "TEXT_PRODUCT":
  125 + return "Label Name";
  126 + case "TEXT_PRICE":
  127 + return "Price";
  128 + case "NUTRITION":
  129 + return "Nutrition Facts";
  130 + case "DURATION":
  131 + return "Duration";
  132 + case "DATE":
  133 + return "Current Date";
  134 + case "TIME":
  135 + return "Current Time";
  136 + case "BARCODE":
  137 + return "Barcode";
  138 + case "QRCODE":
  139 + return "QR Code";
  140 + case "BLANK":
  141 + return "Blank Space";
  142 + case "IMAGE":
  143 + return "Image";
  144 + case "WEIGHT_PRICE":
  145 + return "Weight Price";
  146 + case "TEXT_STATIC":
  147 + return "Text";
  148 + default:
  149 + return el.type.replace(/_/g, " ");
  150 + }
  151 +}
  152 +
27 153 export interface LabelTemplate {
28 154 id: string;
29 155 name: string;
... ... @@ -42,6 +168,8 @@ export interface LabelTemplate {
42 168  
43 169 export interface LabelElement {
44 170 id: string;
  171 + /** 组件名,接口必填;录入表表头展示 */
  172 + elementName?: string | null;
45 173 type: ElementType;
46 174 x: number;
47 175 y: number;
... ... @@ -53,6 +181,13 @@ export interface LabelElement {
53 181 orderNum?: number;
54 182 zIndex?: number;
55 183 valueSourceType?: string;
  184 + /** 与 App 打印入参 printInputJson 对齐的键(接口可能返回 PascalCase,读时兼容) */
  185 + inputKey?: string | null;
  186 + /**
  187 + * 元素库来源:`英文分组前缀_面板字段英文名`,如 `print_Multiple Options`、`label_Label Name`。
  188 + * 旧数据可能仍为中文分组名,保存/导出时会改写为此格式。
  189 + */
  190 + libraryCategory?: string | null;
56 191 isRequiredInput?: boolean;
57 192 config: Record<string, unknown>;
58 193 }
... ... @@ -160,6 +295,14 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl
160 295  
161 296 // ========== API 相关类型定义 ==========
162 297  
  298 +/** 接口 4.4 `templateProductDefaults[]`:产品 + 标签类型 + 元素默认值(elementId → 文本) */
  299 +export type LabelTemplateProductDefaultDto = {
  300 + productId: string;
  301 + labelTypeId: string;
  302 + defaultValues: Record<string, string>;
  303 + orderNum: number;
  304 +};
  305 +
163 306 export type LabelTemplateDto = {
164 307 id: string; // TemplateCode(兼容后端 templateCode)
165 308 name?: string | null;
... ... @@ -186,6 +329,8 @@ export type LabelTemplateDto = {
186 329 versionNo?: number | null;
187 330 elements?: LabelElement[] | null;
188 331 appliedLocationIds?: string[] | null;
  332 + /** 详情/编辑:模板与产品默认值(录入数据表) */
  333 + templateProductDefaults?: LabelTemplateProductDefaultDto[] | null;
189 334 creationTime?: string | null;
190 335 };
191 336  
... ... @@ -204,6 +349,25 @@ export type LabelTemplateGetListInput = {
204 349 state?: boolean;
205 350 };
206 351  
  352 +/** 提交后端的 elements[] 项(与 LabelTemplateEditor 保存结构一致) */
  353 +export type LabelTemplateApiElement = {
  354 + id: string;
  355 + elementName: string;
  356 + type: ElementType;
  357 + x: number;
  358 + y: number;
  359 + width: number;
  360 + height: number;
  361 + rotation: Rotation;
  362 + border: Border;
  363 + zIndex: number;
  364 + orderNum: number;
  365 + valueSourceType: string;
  366 + inputKey?: string;
  367 + isRequiredInput: boolean;
  368 + config: Record<string, unknown>;
  369 +};
  370 +
207 371 export type LabelTemplateCreateInput = {
208 372 id: string; // TemplateCode
209 373 name: string;
... ... @@ -215,12 +379,37 @@ export type LabelTemplateCreateInput = {
215 379 showRuler?: boolean;
216 380 showGrid?: boolean;
217 381 state?: boolean;
218   - elements: LabelElement[];
  382 + /** 提交后端的 elements(与 labelElementsToApiPayload 输出一致) */
  383 + elements: LabelTemplateApiElement[];
219 384 appliedLocationIds?: string[]; // 当 appliedLocation=SPECIFIED 时必填
  385 + /** 仅更新接口使用;新增模板后端忽略 */
  386 + templateProductDefaults?: LabelTemplateProductDefaultDto[];
220 387 };
221 388  
222 389 export type LabelTemplateUpdateInput = LabelTemplateCreateInput;
223 390  
  391 +export function labelElementsToApiPayload(elements: LabelElement[]): LabelTemplateApiElement[] {
  392 + return elements.map((el, index) => ({
  393 + id: el.id,
  394 + elementName: (el.elementName ?? "").trim(),
  395 + type: el.type,
  396 + x: el.x,
  397 + y: el.y,
  398 + width: el.width,
  399 + height: el.height,
  400 + rotation: el.rotation,
  401 + border: el.border,
  402 + zIndex: el.zIndex ?? index + 1,
  403 + orderNum: el.orderNum ?? index + 1,
  404 + valueSourceType: resolvedValueSourceTypeForSave(el),
  405 + ...(el.inputKey != null && String(el.inputKey).trim() !== ""
  406 + ? { inputKey: String(el.inputKey).trim() }
  407 + : {}),
  408 + isRequiredInput: el.isRequiredInput ?? false,
  409 + config: (el.config ?? {}) as Record<string, unknown>,
  410 + }));
  411 +}
  412 +
224 413 /** 将列表/详情 DTO 的 appliedLocation 规范为编辑器使用的 ALL | SPECIFIED */
225 414 export function appliedLocationToEditor(dto: {
226 415 appliedLocation?: string | null;
... ... @@ -244,3 +433,140 @@ export function defaultValueSourceTypeForElement(type: ElementType): string {
244 433 return "FIXED";
245 434 }
246 435 }
  436 +
  437 +/** 画布「打印时输入」区拖入的元素,或与后端约定 valueSourceType=PRINT_INPUT */
  438 +export function isPrintInputElement(el: LabelElement): boolean {
  439 + const vst = String(el.valueSourceType ?? "").trim().toUpperCase();
  440 + if (vst === "PRINT_INPUT") return true;
  441 + const cfg = (el.config ?? {}) as Record<string, unknown>;
  442 + if (el.type === "TEXT_STATIC" && cfg.inputType != null && String(cfg.inputType).trim() !== "") {
  443 + return true;
  444 + }
  445 + if (el.type === "DATE" && (cfg.inputType === "datetime" || cfg.inputType === "date")) {
  446 + return true;
  447 + }
  448 + if (el.type === "WEIGHT") return true;
  449 + return false;
  450 +}
  451 +
  452 +/** 录入数据表列标题 */
  453 +export function printInputFieldLabel(el: LabelElement): string {
  454 + const cfg = (el.config ?? {}) as Record<string, unknown>;
  455 + const t = typeof cfg.text === "string" ? cfg.text.trim() : "";
  456 + if (t) return t;
  457 + const it = cfg.inputType as string | undefined;
  458 + if (it === "number") return "Number";
  459 + if (it === "text") return "Text";
  460 + if (it === "options") return "Multiple Options";
  461 + if (it === "datetime" || it === "date") return "Date & Time";
  462 + if (el.type === "WEIGHT") return "Weight";
  463 + return el.type.replace(/_/g, " ");
  464 +}
  465 +
  466 +/** 接口里 inputKey 可能是 InputKey */
  467 +export function elementInputKey(el: LabelElement): string {
  468 + const e = el as LabelElement & { InputKey?: string | null };
  469 + const k = el.inputKey ?? e.InputKey;
  470 + return typeof k === "string" ? k.trim() : "";
  471 +}
  472 +
  473 +/**
  474 + * 录入数据表格:仅 ValueSourceType=FIXED 的列可填;AUTO_DB / PRINT_INPUT 在 App 解析或打印时处理。
  475 + */
  476 +export function isDataEntryTableColumnElement(el: LabelElement): boolean {
  477 + const vst = normalizeValueSourceTypeForElement(el);
  478 + if (vst !== "FIXED") return false;
  479 + if (el.type === "BLANK") return false;
  480 + return true;
  481 +}
  482 +
  483 +/** 录入表表头:优先 elementName,其次 inputKey,再推导 */
  484 +export function dataEntryColumnLabel(el: LabelElement): string {
  485 + const name = (el.elementName ?? "").trim();
  486 + if (name) return name;
  487 + const ik = elementInputKey(el);
  488 + if (ik) return ik;
  489 + const cfg = (el.config ?? {}) as Record<string, unknown>;
  490 + if (el.type === "TEXT_PRODUCT") {
  491 + const t = typeof cfg.text === "string" ? cfg.text.trim() : "";
  492 + return t || "Product name";
  493 + }
  494 + if (el.type === "TEXT_PRICE") {
  495 + const t = typeof cfg.text === "string" ? cfg.text.trim() : "";
  496 + return t || "Price";
  497 + }
  498 + if (el.type === "IMAGE") return "Image";
  499 + return printInputFieldLabel(el);
  500 +}
  501 +
  502 +/** 与详情接口 elements 顺序一致(orderNum → zIndex → 原序) */
  503 +export function sortTemplateElementsForDisplay(elements: LabelElement[]): LabelElement[] {
  504 + return [...elements].sort((a, b) => {
  505 + const oa = a.orderNum ?? 0;
  506 + const ob = b.orderNum ?? 0;
  507 + if (oa !== ob) return oa - ob;
  508 + const za = a.zIndex ?? 0;
  509 + const zb = b.zIndex ?? 0;
  510 + if (za !== zb) return za - zb;
  511 + return 0;
  512 + });
  513 +}
  514 +
  515 +/** 统一大写,便于比较(兼容旧接口:valueSourceType=FIXED 但 config 为打印时输入) */
  516 +export function normalizeValueSourceTypeForElement(el: LabelElement): string {
  517 + const v = String(el.valueSourceType ?? "").trim().toUpperCase();
  518 + if (v === "FIXED" && isPrintInputElement({ ...el, valueSourceType: "" })) {
  519 + return "PRINT_INPUT";
  520 + }
  521 + if (v === "FIXED" || v === "AUTO_DB" || v === "PRINT_INPUT") return v;
  522 + const lc = (el.libraryCategory ?? "").trim();
  523 + if (lc.startsWith("print_")) return "PRINT_INPUT";
  524 + if (lc.startsWith("auto_")) return "AUTO_DB";
  525 + if (lc.startsWith("template_") || lc.startsWith("label_")) return "FIXED";
  526 + const rawGroup = el.libraryCategory ?? "";
  527 + if (isElementLibraryCategory(rawGroup)) {
  528 + return valueSourceTypeForLibraryCategory(rawGroup);
  529 + }
  530 + if (isPrintInputElement({ ...el, valueSourceType: "" })) return "PRINT_INPUT";
  531 + return "FIXED";
  532 +}
  533 +
  534 +/** 保存接口时写入的 ValueSourceType(仅 FIXED / AUTO_DB / PRINT_INPUT) */
  535 +export function resolvedValueSourceTypeForSave(el: LabelElement): string {
  536 + return normalizeValueSourceTypeForElement(el);
  537 +}
  538 +
  539 +/**
  540 + * 无 libraryCategory 时的兜底(无法从 type 区分「保质期日期」与「当前日期」等同型元素,新元素应以面板点击为准)。
  541 + */
  542 +export function inferElementLibraryCategory(el: LabelElement): ElementLibraryCategory {
  543 + if (isPrintInputElement(el)) return "打印时输入";
  544 + switch (el.type) {
  545 + case "TEXT_PRODUCT":
  546 + case "NUTRITION":
  547 + case "DURATION":
  548 + case "TEXT_PRICE":
  549 + case "WEIGHT_PRICE":
  550 + return "标签信息";
  551 + case "DATE":
  552 + case "TIME":
  553 + return "自动生成";
  554 + case "TEXT_STATIC":
  555 + case "BARCODE":
  556 + case "QRCODE":
  557 + case "IMAGE":
  558 + case "BLANK":
  559 + default:
  560 + return "模版信息";
  561 + }
  562 +}
  563 +
  564 +/** 导出 / 提交后端时写入的 libraryCategory 字符串 */
  565 +export function resolvedLibraryCategoryForPersist(el: LabelElement): string {
  566 + const c = el.libraryCategory?.trim();
  567 + if (c && isComposedLibraryCategoryValue(c)) return c;
  568 + const group =
  569 + c && isElementLibraryCategory(c) ? c : inferElementLibraryCategory(el);
  570 + const fieldName = inferPaletteEnglishLabel(el);
  571 + return composeLibraryCategoryForPersist(group, fieldName);
  572 +}
... ...