Commit 143afd59f4391431617fa291d152a6b6b04475c4
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) => { |
| 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 = () => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 "../ui/switch"; |
| 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 'react'; |
| 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: 'cm' | 'inch'): 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 '../../../types/location'; |
| 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 | 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 "../ui/switch"; |
| 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 "../ui/switch"; |
| 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 "../ui/switch"; |
| 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 "../ui/label"; |
| 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 "../ui/switch"; |
| 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
美国版/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 | +} | ... | ... |