Commit 3af4878ddc4ae2ea4d2730a60724bfc18c98450c

Authored by 杨鑫
1 parent 6faaf539

产品 标签 关联

Showing 30 changed files with 1746 additions and 1463 deletions
标签模块接口对接说明(1).md deleted
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`)。  
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 -  
26 ----  
27 -  
28 -## 接口 1:Label Categories(标签分类)  
29 -  
30 -### 1.1 分页列表  
31 -  
32 -方法:`GET /api/app/label-category`  
33 -  
34 -入参(`LabelCategoryGetListInputVo`,查询参数):  
35 -  
36 -- `skipCount`(int)  
37 -- `maxResultCount`(int)  
38 -- `sorting`(string,可选)  
39 -- `keyword`(string,可选)  
40 -- `state`(boolean,可选)  
41 -  
42 -示例(查询参数):  
43 -  
44 -```json  
45 -{  
46 - "skipCount": 0,  
47 - "maxResultCount": 10,  
48 - "keyword": "Prep",  
49 - "state": true  
50 -}  
51 -```  
52 -  
53 -### 1.2 详情  
54 -  
55 -方法:`GET /api/app/label-category/{id}`  
56 -  
57 -入参:  
58 -  
59 -- `id`:分类 Id(字符串)  
60 -  
61 -### 1.3 新增  
62 -  
63 -方法:`POST /api/app/label-category`  
64 -  
65 -入参(Body:`LabelCategoryCreateInputVo`):  
66 -  
67 -```json  
68 -{  
69 - "categoryCode": "CAT_PREP",  
70 - "categoryName": "Prep",  
71 - "categoryPhotoUrl": "https://cdn.example.com/cat-prep.png",  
72 - "state": true,  
73 - "orderNum": 1  
74 -}  
75 -```  
76 -  
77 -### 1.4 编辑  
78 -  
79 -方法:`PUT /api/app/label-category/{id}`  
80 -  
81 -入参(Body:`LabelCategoryUpdateInputVo`,字段同创建):  
82 -  
83 -```json  
84 -{  
85 - "categoryCode": "CAT_PREP",  
86 - "categoryName": "Prep",  
87 - "categoryPhotoUrl": null,  
88 - "state": true,  
89 - "orderNum": 2  
90 -}  
91 -```  
92 -  
93 -### 1.5 删除(逻辑删除)  
94 -  
95 -方法:`DELETE /api/app/label-category/{id}`  
96 -  
97 -入参:  
98 -  
99 -- `id`:分类 Id(字符串)  
100 -  
101 -删除校验:  
102 -- 若该分类已被 `fl_label` 引用,则抛出友好错误,禁止删除。  
103 -  
104 ----  
105 -  
106 -## 接口 2:Label Types(标签类型)  
107 -  
108 -### 2.1 分页列表  
109 -  
110 -方法:`GET /api/app/label-type`  
111 -  
112 -入参(`LabelTypeGetListInputVo`,查询参数):  
113 -  
114 -```json  
115 -{  
116 - "skipCount": 0,  
117 - "maxResultCount": 10,  
118 - "keyword": "Defrost",  
119 - "state": true  
120 -}  
121 -```  
122 -  
123 -### 2.2 详情  
124 -  
125 -方法:`GET /api/app/label-type/{id}`  
126 -  
127 -入参:  
128 -  
129 -- `id`:类型 Id(字符串)  
130 -  
131 -### 2.3 新增  
132 -  
133 -方法:`POST /api/app/label-type`  
134 -  
135 -入参(Body:`LabelTypeCreateInputVo`):  
136 -  
137 -```json  
138 -{  
139 - "typeCode": "TYPE_DEFROST",  
140 - "typeName": "Defrost",  
141 - "state": true,  
142 - "orderNum": 1  
143 -}  
144 -```  
145 -  
146 -### 2.4 编辑  
147 -  
148 -方法:`PUT /api/app/label-type/{id}`  
149 -  
150 -入参(Body:`LabelTypeUpdateInputVo`,字段同创建):  
151 -  
152 -```json  
153 -{  
154 - "typeCode": "TYPE_DEFROST",  
155 - "typeName": "Defrost",  
156 - "state": true,  
157 - "orderNum": 2  
158 -}  
159 -```  
160 -  
161 -### 2.5 删除(逻辑删除)  
162 -  
163 -方法:`DELETE /api/app/label-type/{id}`  
164 -  
165 -删除校验:  
166 -- 若该类型已被 `fl_label` 引用,则禁止删除。  
167 -  
168 ----  
169 -  
170 -## 接口 3:Multiple Options(多选项字典)  
171 -  
172 -### 3.1 分页列表  
173 -  
174 -方法:`GET /api/app/label-multiple-option`  
175 -  
176 -入参(`LabelMultipleOptionGetListInputVo`,查询参数):  
177 -  
178 -```json  
179 -{  
180 - "skipCount": 0,  
181 - "maxResultCount": 10,  
182 - "keyword": "Allergens",  
183 - "state": true  
184 -}  
185 -```  
186 -  
187 -### 3.2 详情  
188 -  
189 -方法:`GET /api/app/label-multiple-option/{id}`  
190 -  
191 -入参:  
192 -  
193 -- `id`:多选项 Id(字符串)  
194 -  
195 -### 3.3 新增  
196 -  
197 -方法:`POST /api/app/label-multiple-option`  
198 -  
199 -入参(Body:`LabelMultipleOptionCreateInputVo`):  
200 -  
201 -说明:`optionValuesJson` 为 **JSON 字符串**,值为 string 数组的序列化结果(与库表/后端 DTO 一致),例如 `["Peanuts","Dairy"]` 对应字符串 `"[\"Peanuts\",\"Dairy\"]"`。  
202 -  
203 -```json  
204 -{  
205 - "optionCode": "OPT_ALLERGENS",  
206 - "optionName": "Allergens",  
207 - "optionValuesJson": "[\"Peanuts\",\"Dairy\",\"Gluten\",\"Soy\"]",  
208 - "state": true,  
209 - "orderNum": 1  
210 -}  
211 -```  
212 -  
213 -### 3.4 编辑  
214 -  
215 -方法:`PUT /api/app/label-multiple-option/{id}`  
216 -  
217 -入参(Body:`LabelMultipleOptionUpdateInputVo`,字段同创建):  
218 -  
219 -```json  
220 -{  
221 - "optionCode": "OPT_ALLERGENS",  
222 - "optionName": "Allergens",  
223 - "optionValuesJson": "[\"Peanuts\",\"Dairy\"]",  
224 - "state": true,  
225 - "orderNum": 2  
226 -}  
227 -```  
228 -  
229 -### 3.5 删除(逻辑删除)  
230 -  
231 -方法:`DELETE /api/app/label-multiple-option/{id}`  
232 -  
233 ----  
234 -  
235 -## 接口 4:Label Templates(标签模板)  
236 -  
237 -说明:  
238 -- 模板标识入参 `id` 使用 `fl_label_template.TemplateCode`。  
239 -- 创建/编辑的 Body 字段名对齐你前端 editor JSON(`id/name/appliedLocation/elements/config`)。  
240 -  
241 -### 4.1 分页列表  
242 -  
243 -方法:`GET /api/app/label-template`  
244 -  
245 -入参(`LabelTemplateGetListInputVo`,查询参数):  
246 -  
247 -```json  
248 -{  
249 - "skipCount": 0,  
250 - "maxResultCount": 10,  
251 - "keyword": "测试模板",  
252 - "locationId": "11111111-1111-1111-1111-111111111111",  
253 - "labelType": "PRICE",  
254 - "state": true  
255 -}  
256 -```  
257 -  
258 -### 4.2 详情  
259 -  
260 -方法:`GET /api/app/label-template/{id}`  
261 -  
262 -入参:  
263 -  
264 -- `id`:模板编码 `TemplateCode`(字符串)  
265 -  
266 -### 4.3 新增模板  
267 -  
268 -方法:`POST /api/app/label-template`  
269 -  
270 -入参(Body:`LabelTemplateCreateInputVo`):  
271 -  
272 -```json  
273 -{  
274 - "id": "TPL_TEST_001",  
275 - "name": "测试模板-价格签(4x6)",  
276 - "labelType": "PRICE",  
277 - "unit": "inch",  
278 - "width": 4,  
279 - "height": 6,  
280 - "appliedLocation": "ALL",  
281 - "showRuler": true,  
282 - "showGrid": true,  
283 - "state": true,  
284 - "elements": [  
285 - {  
286 - "id": "el-fixed-title",  
287 - "type": "TEXT_STATIC",  
288 - "x": 32,  
289 - "y": 24,  
290 - "width": 160,  
291 - "height": 24,  
292 - "rotation": "horizontal",  
293 - "border": "none",  
294 - "zIndex": 1,  
295 - "orderNum": 1,  
296 - "valueSourceType": "FIXED",  
297 - "isRequiredInput": false,  
298 - "config": {  
299 - "text": "商品名",  
300 - "fontFamily": "Arial",  
301 - "fontSize": 14,  
302 - "fontWeight": "bold",  
303 - "textAlign": "left"  
304 - }  
305 - }  
306 - ],  
307 - "appliedLocationIds": []  
308 -}  
309 -```  
310 -  
311 -说明:  
312 -- 当 `appliedLocation=SPECIFIED` 时,`appliedLocationIds` 必须至少选择一个门店。  
313 -  
314 -### 4.4 编辑模板  
315 -  
316 -方法:`PUT /api/app/label-template/{id}`  
317 -  
318 -入参:  
319 -- Path:`id` 是当前模板编码(TemplateCode)  
320 -- Body:字段同新增(`id/name/elements/...`)  
321 -  
322 -示例(编辑:同样字段,appliedLocation 切到 SPECIFIED):  
323 -  
324 -```json  
325 -{  
326 - "id": "TPL_TEST_001",  
327 - "name": "测试模板-价格签(4x6) v2",  
328 - "labelType": "PRICE",  
329 - "unit": "inch",  
330 - "width": 4,  
331 - "height": 6,  
332 - "appliedLocation": "SPECIFIED",  
333 - "showRuler": true,  
334 - "showGrid": true,  
335 - "state": true,  
336 - "elements": [],  
337 - "appliedLocationIds": ["11111111-1111-1111-1111-111111111111"]  
338 -}  
339 -```  
340 -  
341 -版本:  
342 -- `VersionNo` 会在编辑时自动 `+1`。  
343 -- `elements` 会按传入内容全量重建。  
344 -  
345 -### 4.5 删除(逻辑删除)  
346 -  
347 -方法:`DELETE /api/app/label-template/{id}`  
348 -  
349 -入参:  
350 -- `id`:模板编码 `TemplateCode`  
351 -  
352 -删除校验:  
353 -- 若该模板已被 `fl_label` 引用,则禁止删除。  
354 -  
355 ----  
356 -  
357 -## 接口 5:Labels(按产品展示多个标签)  
358 -  
359 -说明:  
360 -- 列表接口按 `ProductId` 查询,一个产品会对应多条标签记录。  
361 -- 标签详情/编辑/删除的 `id` 使用 `fl_label.LabelCode`。  
362 -  
363 -### 5.1 分页列表(按产品)  
364 -  
365 -方法:`GET /api/app/label`  
366 -  
367 -入参(`LabelGetListInputVo`,查询参数):  
368 -  
369 -```json  
370 -{  
371 - "skipCount": 0,  
372 - "maxResultCount": 10,  
373 - "sorting": "",  
374 - "keyword": "早餐",  
375 - "locationId": "11111111-1111-1111-1111-111111111111",  
376 - "productId": "22222222-2222-2222-2222-222222222222",  
377 - "labelCategoryId": "33333333-3333-3333-3333-333333333333",  
378 - "labelTypeId": "44444444-4444-4444-4444-444444444444",  
379 - "templateCode": "TPL_TEST_001",  
380 - "state": true  
381 -}  
382 -```  
383 -  
384 -### 5.2 详情  
385 -  
386 -方法:`GET /api/app/label/{id}`  
387 -  
388 -入参:  
389 -- `id`:标签编码 `LabelCode`  
390 -  
391 -返回:  
392 -- `productIds`:该标签绑定的产品Id 列表  
393 -  
394 -### 5.3 新增标签  
395 -  
396 -方法:`POST /api/app/label`  
397 -  
398 -入参(Body:`LabelCreateInputVo`):  
399 -  
400 -```json  
401 -{  
402 - "labelCode": "LBL_TEST_001",  
403 - "labelName": "早餐标签",  
404 - "templateCode": "TPL_TEST_001",  
405 - "locationId": "11111111-1111-1111-1111-111111111111",  
406 - "labelCategoryId": "33333333-3333-3333-3333-333333333333",  
407 - "labelTypeId": "44444444-4444-4444-4444-444444444444",  
408 - "productIds": ["22222222-2222-2222-2222-222222222222"],  
409 - "labelInfoJson": { "note": "测试标签1" },  
410 - "state": true  
411 -}  
412 -```  
413 -  
414 -校验:  
415 -- `productIds` 至少 1 个  
416 -- `templateCode/locationId/labelCategoryId/labelTypeId` 不能为空  
417 -  
418 -### 5.4 编辑标签  
419 -  
420 -方法:`PUT /api/app/label/{id}`  
421 -  
422 -入参:  
423 -- Path:`id` 为当前标签编码 `LabelCode`  
424 -- Body:字段同创建(`LabelUpdateInputVo`)  
425 -  
426 -```json  
427 -{  
428 - "labelName": "早餐标签 v2",  
429 - "templateCode": "TPL_TEST_001",  
430 - "locationId": "11111111-1111-1111-1111-111111111111",  
431 - "labelCategoryId": "33333333-3333-3333-3333-333333333333",  
432 - "labelTypeId": "44444444-4444-4444-4444-444444444444",  
433 - "productIds": ["22222222-2222-2222-2222-222222222222"],  
434 - "labelInfoJson": { "note": "测试标签1 v2" },  
435 - "state": true  
436 -}  
437 -```  
438 -  
439 -关联维护:  
440 -- `fl_label_product` 会按新 `productIds` 重建。  
441 -  
442 -### 5.5 删除标签(逻辑删除)  
443 -  
444 -方法:`DELETE /api/app/label/{id}`  
445 -  
446 -入参:  
447 -- `id`:标签编码 `LabelCode`  
448 -  
449 -删除行为:  
450 -- 逻辑删除 `fl_label`  
451 -- 删除该标签对应的 `fl_label_product` 关联  
452 -  
453 ----  
454 -## 接口 6:Products(产品)  
455 -  
456 -说明:  
457 -- 产品表:`fl_product`  
458 -- 删除为逻辑删除:`IsDeleted = true`  
459 -  
460 -### 6.1 分页列表  
461 -  
462 -方法:`GET /api/app/product`  
463 -  
464 -入参(`ProductGetListInputVo`,查询参数):  
465 -```json  
466 -{  
467 - "skipCount": 0,  
468 - "maxResultCount": 10,  
469 - "sorting": "",  
470 - "keyword": "Chicken",  
471 - "state": true  
472 -}  
473 -```  
474 -  
475 -### 6.2 详情  
476 -  
477 -方法:`GET /api/app/product/{id}`  
478 -  
479 -入参:  
480 -- `id`:产品Id(`fl_product.Id`)  
481 -  
482 -### 6.3 新增产品  
483 -  
484 -方法:`POST /api/app/product`  
485 -  
486 -入参(Body:`ProductCreateInputVo`):  
487 -```json  
488 -{  
489 - "productCode": "PRD_TEST_001",  
490 - "productName": "Chicken",  
491 - "categoryName": "Meat",  
492 - "productImageUrl": "https://example.com/img.png",  
493 - "state": true  
494 -}  
495 -```  
496 -  
497 -校验:  
498 -- `productCode/productName` 不能为空  
499 -- `productCode` 不能与未删除的数据重复  
500 -  
501 -### 6.4 编辑产品  
502 -  
503 -方法:`PUT /api/app/product/{id}`  
504 -  
505 -入参:  
506 -- Path:`id` 为当前产品Id(`fl_product.Id`)  
507 -- Body:字段同新增(`ProductUpdateInputVo`)  
508 -  
509 -### 6.5 删除(逻辑删除)  
510 -  
511 -方法:`DELETE /api/app/product/{id}`  
512 -  
513 -入参:  
514 -- `id`:产品Id  
515 -  
516 ----  
517 -## 接口 7:Product-Location(门店-产品关联)  
518 -  
519 -说明:  
520 -- 关联表:`fl_location_product`  
521 -- 关联按门店进行批量替换:  
522 - - `Create`:在门店下新增未存在的 product 关联  
523 - - `Update`:替换该门店下全部关联(先删后建)  
524 - - `Delete`:删除该门店下全部关联  
525 -  
526 -### 7.1 分页列表  
527 -  
528 -方法:`GET /api/app/product-location`  
529 -  
530 -入参(`ProductLocationGetListInputVo`,查询参数):  
531 -```json  
532 -{  
533 - "skipCount": 0,  
534 - "maxResultCount": 10,  
535 - "sorting": "",  
536 - "locationId": "11111111-1111-1111-1111-111111111111",  
537 - "productId": "22222222-2222-2222-2222-222222222222"  
538 -}  
539 -```  
540 -  
541 -### 7.2 获取门店下全部产品  
542 -  
543 -方法:`GET /api/app/product-location/{id}`  
544 -  
545 -入参:  
546 -- `id`:门店Id(`location.Id`,string 表示)  
547 -  
548 -返回:  
549 -- 门店Id + 该门店关联的产品列表  
550 -  
551 -### 7.3 新增/建立门店关联  
552 -  
553 -方法:`POST /api/app/product-location`  
554 -  
555 -入参(Body:`ProductLocationCreateInputVo`):  
556 -```json  
557 -{  
558 - "locationId": "11111111-1111-1111-1111-111111111111",  
559 - "productIds": ["22222222-2222-2222-2222-222222222222"]  
560 -}  
561 -```  
562 -  
563 -校验:  
564 -- `locationId` 对应门店必须存在  
565 -- `productIds` 必须都存在于 `fl_product` 且未删除  
566 -  
567 -### 7.4 编辑/替换门店关联  
568 -  
569 -方法:`PUT /api/app/product-location/{id}`  
570 -  
571 -入参:  
572 -- Path:`id` 为门店Id  
573 -- Body:`ProductLocationUpdateInputVo`  
574 -```json  
575 -{  
576 - "productIds": ["22222222-2222-2222-2222-222222222222"]  
577 -}  
578 -```  
579 -  
580 -### 7.5 删除门店关联(按门店删除全部)  
581 -  
582 -方法:`DELETE /api/app/product-location/{id}`  
583 -  
584 -入参:  
585 -- `id`:门店Id  
586 -  
美国版/Food Labeling Management App UniApp/src/components/LocationPicker.vue
1 <template> 1 <template>
2 <view class="loc-root"> 2 <view class="loc-root">
3 <view class="loc-trigger" @click.stop="openPicker"> 3 <view class="loc-trigger" @click.stop="openPicker">
4 - <text class="loc-text">{{ displayCode || '—' }}</text> 4 + <text class="loc-text">{{ displayLocationName || '—' }}</text>
5 <AppIcon name="chevronDown" size="sm" color="white" /> 5 <AppIcon name="chevronDown" size="sm" color="white" />
6 </view> 6 </view>
7 7
@@ -48,7 +48,7 @@ import { @@ -48,7 +48,7 @@ import {
48 getBoundLocations, 48 getBoundLocations,
49 getCurrentLocationCode, 49 getCurrentLocationCode,
50 } from '../utils/stores' 50 } from '../utils/stores'
51 -import type { UsAppBoundLocationDto } from '../services/usAppAuth' 51 +import type { UsAppBoundLocationDto } from '../types/usAppBound'
52 52
53 const { t } = useI18n() 53 const { t } = useI18n()
54 const stores = ref<UsAppBoundLocationDto[]>([]) 54 const stores = ref<UsAppBoundLocationDto[]>([])
@@ -59,12 +59,21 @@ function refreshList() { @@ -59,12 +59,21 @@ function refreshList() {
59 stores.value = getBoundLocations().filter((s) => s.state !== false) 59 stores.value = getBoundLocations().filter((s) => s.state !== false)
60 } 60 }
61 61
62 -const displayCode = computed(() => { 62 +/** 药丸展示门店名称(与弹窗内选中项一致);无名称时再退回编码 */
  63 +const displayLocationName = computed(() => {
  64 + const fromStorage = uni.getStorageSync('storeName')
  65 + if (typeof fromStorage === 'string' && fromStorage.trim()) return fromStorage.trim()
63 if (currentId.value) { 66 if (currentId.value) {
64 const row = stores.value.find((x) => x.id === currentId.value) 67 const row = stores.value.find((x) => x.id === currentId.value)
65 - if (row?.locationCode) return row.locationCode 68 + if (row?.locationName?.trim()) return row.locationName.trim()
66 } 69 }
67 - return getCurrentLocationCode() 70 + const id = getCurrentStoreId()
  71 + if (id) {
  72 + const row = getBoundLocations().find((x) => x.id === id)
  73 + if (row?.locationName?.trim()) return row.locationName.trim()
  74 + }
  75 + const code = getCurrentLocationCode()
  76 + return code || ''
68 }) 77 })
69 78
70 function openPicker() { 79 function openPicker() {
@@ -111,6 +120,10 @@ const handleSelect = (s: UsAppBoundLocationDto) =&gt; { @@ -111,6 +120,10 @@ const handleSelect = (s: UsAppBoundLocationDto) =&gt; {
111 font-size: 22rpx; 120 font-size: 22rpx;
112 color: rgba(255, 255, 255, 0.9); 121 color: rgba(255, 255, 255, 0.9);
113 font-weight: 500; 122 font-weight: 500;
  123 + max-width: 320rpx;
  124 + overflow: hidden;
  125 + text-overflow: ellipsis;
  126 + white-space: nowrap;
114 } 127 }
115 128
116 .loc-trigger .icon-wrap { 129 .loc-trigger .icon-wrap {
美国版/Food Labeling Management App UniApp/src/pages/index/index.vue
@@ -51,7 +51,7 @@ import { getAccessToken } from &#39;../../utils/authSession&#39; @@ -51,7 +51,7 @@ import { getAccessToken } from &#39;../../utils/authSession&#39;
51 const { t } = useI18n() 51 const { t } = useI18n()
52 const statusBarHeight = getStatusBarHeight() 52 const statusBarHeight = getStatusBarHeight()
53 53
54 -const storeName = computed(() => uni.getStorageSync('storeName') || 'MedVantage') 54 +const storeName = computed(() => uni.getStorageSync('storeName') || 'None Selected')
55 const isMenuOpen = ref(false) 55 const isMenuOpen = ref(false)
56 56
57 onShow(() => { 57 onShow(() => {
美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue
@@ -68,7 +68,9 @@ import { useI18n } from &#39;vue-i18n&#39; @@ -68,7 +68,9 @@ import { useI18n } from &#39;vue-i18n&#39;
68 import { onShow } from '@dcloudio/uni-app' 68 import { onShow } from '@dcloudio/uni-app'
69 import AppIcon from '../../components/AppIcon.vue' 69 import AppIcon from '../../components/AppIcon.vue'
70 import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' 70 import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar'
71 -import { usAppFetchMyLocations, type UsAppBoundLocationDto } from '../../services/usAppAuth' 71 +import { usAppFetchMyLocations } from '../../services/usAppAuth'
  72 +import type { UsAppBoundLocationDto } from '../../types/usAppBound'
  73 +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
72 import { setBoundLocations, getBoundLocations } from '../../utils/authSession' 74 import { setBoundLocations, getBoundLocations } from '../../utils/authSession'
73 import { switchStore } from '../../utils/stores' 75 import { switchStore } from '../../utils/stores'
74 76
@@ -91,7 +93,8 @@ async function refreshFromApi() { @@ -91,7 +93,8 @@ async function refreshFromApi() {
91 try { 93 try {
92 const list = await usAppFetchMyLocations() 94 const list = await usAppFetchMyLocations()
93 applyList(list) 95 applyList(list)
94 - } catch { 96 + } catch (e) {
  97 + if (isUsAppSessionExpiredError(e)) return
95 applyList(getBoundLocations()) 98 applyList(getBoundLocations())
96 uni.showToast({ title: t('login.refreshStoresFail'), icon: 'none' }) 99 uni.showToast({ title: t('login.refreshStoresFail'), icon: 'none' })
97 } finally { 100 } finally {
美国版/Food Labeling Management App UniApp/src/services/usAppAuth.ts
1 -import { buildApiUrl } from '../utils/apiBase' 1 +import type { UsAppBoundLocationDto } from '../types/usAppBound'
  2 +import { usAppApiRequest } from '../utils/usAppApiRequest'
2 3
3 -/** 与后端 UsAppBoundLocationDto 对齐 */  
4 -export interface UsAppBoundLocationDto {  
5 - id: string  
6 - locationCode: string  
7 - locationName: string  
8 - fullAddress: string  
9 - state: boolean  
10 -} 4 +export type { UsAppBoundLocationDto }
11 5
12 export interface UsAppLoginInput { 6 export interface UsAppLoginInput {
13 email: string 7 email: string
@@ -48,102 +42,12 @@ function normalizeLocationList(raw: unknown): UsAppBoundLocationDto[] { @@ -48,102 +42,12 @@ function normalizeLocationList(raw: unknown): UsAppBoundLocationDto[] {
48 return arr.map((x) => normalizeLocation(x as Record<string, unknown>)) 42 return arr.map((x) => normalizeLocation(x as Record<string, unknown>))
49 } 43 }
50 44
51 -/**  
52 - * 取出真实业务负载:支持 ABP `result`、以及项目统一包装 `{ succeeded, data }`(data 内为 token / 数组等)  
53 - */  
54 -function unwrap<T>(data: unknown): T {  
55 - if (data == null || typeof data !== 'object') return data as T  
56 - const o = data as Record<string, unknown>  
57 - if ('result' in o && o.result !== undefined) {  
58 - return o.result as T  
59 - }  
60 - const payload = o.data ?? o.Data  
61 - if (payload !== undefined && payload !== null) {  
62 - return payload as T  
63 - }  
64 - return data as T  
65 -}  
66 -  
67 -/** 统一解析后端错误文案(含统一返回体里的 errors、succeeded 等) */  
68 -function parseErrorMessage(data: unknown): string {  
69 - if (data == null) return 'Request failed'  
70 - if (typeof data === 'string') return data  
71 - if (typeof data === 'object') {  
72 - const o = data as Record<string, unknown>  
73 - const errorsRaw = o.errors ?? o.Errors  
74 - if (typeof errorsRaw === 'string' && errorsRaw.trim()) return errorsRaw.trim()  
75 - if (Array.isArray(errorsRaw)) {  
76 - const parts = errorsRaw.map((x) => String(x)).filter(Boolean)  
77 - if (parts.length) return parts.join('; ')  
78 - }  
79 - const err = o.error as Record<string, unknown> | undefined  
80 - if (err && typeof err.message === 'string') return err.message  
81 - if (typeof o.message === 'string') return o.message  
82 - if (typeof o.error_description === 'string') return o.error_description  
83 - }  
84 - return 'Request failed'  
85 -}  
86 -  
87 -/** HTTP 200 但业务失败(如 succeeded: false、体内 statusCode 403) */  
88 -function isBusinessFailurePayload(data: unknown): boolean {  
89 - if (data == null || typeof data !== 'object') return false  
90 - const o = data as Record<string, unknown>  
91 - if (o.succeeded === false || o.Succeeded === false) return true  
92 - const inner = o.statusCode ?? o.StatusCode  
93 - if (typeof inner === 'number' && inner >= 400) return true  
94 - return false  
95 -}  
96 -  
97 -function request<T>(options: {  
98 - path: string  
99 - method: 'GET' | 'POST'  
100 - data?: unknown  
101 - auth?: boolean  
102 -}): Promise<T> {  
103 - const header: Record<string, string> = {  
104 - 'Content-Type': 'application/json',  
105 - Accept: 'application/json',  
106 - }  
107 - if (options.auth) {  
108 - const token = uni.getStorageSync('access_token')  
109 - if (token) header.Authorization = `Bearer ${token}`  
110 - }  
111 -  
112 - return new Promise((resolve, reject) => {  
113 - uni.request({  
114 - url: buildApiUrl(options.path),  
115 - method: options.method,  
116 - data: options.data,  
117 - header,  
118 - success: (res) => {  
119 - const status = res.statusCode ?? 0  
120 - if (status >= 400) {  
121 - reject(new Error(parseErrorMessage(res.data)))  
122 - return  
123 - }  
124 - if (isBusinessFailurePayload(res.data)) {  
125 - reject(new Error(parseErrorMessage(res.data)))  
126 - return  
127 - }  
128 - try {  
129 - const body = unwrap<T>(res.data as unknown)  
130 - resolve(body)  
131 - } catch {  
132 - reject(new Error('Invalid response'))  
133 - }  
134 - },  
135 - fail: (err) => {  
136 - reject(new Error(err.errMsg || 'Network error'))  
137 - },  
138 - })  
139 - })  
140 -}  
141 -  
142 -/** POST /api/app/us-app-auth/login */ 45 +/** POST /api/app/us-app-auth/login(401 不触发全局跳转) */
143 export async function usAppLogin(input: UsAppLoginInput): Promise<UsAppLoginOutputDto> { 46 export async function usAppLogin(input: UsAppLoginInput): Promise<UsAppLoginOutputDto> {
144 - const raw = await request<unknown>({ 47 + const raw = await usAppApiRequest<unknown>({
145 path: '/api/app/us-app-auth/login', 48 path: '/api/app/us-app-auth/login',
146 method: 'POST', 49 method: 'POST',
  50 + skipUnauthorizedRedirect: true,
147 data: { 51 data: {
148 email: input.email.trim(), 52 email: input.email.trim(),
149 password: input.password, 53 password: input.password,
@@ -156,7 +60,7 @@ export async function usAppLogin(input: UsAppLoginInput): Promise&lt;UsAppLoginOutp @@ -156,7 +60,7 @@ export async function usAppLogin(input: UsAppLoginInput): Promise&lt;UsAppLoginOutp
156 60
157 /** GET /api/app/us-app-auth/my-locations */ 61 /** GET /api/app/us-app-auth/my-locations */
158 export async function usAppFetchMyLocations(): Promise<UsAppBoundLocationDto[]> { 62 export async function usAppFetchMyLocations(): Promise<UsAppBoundLocationDto[]> {
159 - const raw = await request<unknown>({ 63 + const raw = await usAppApiRequest<unknown>({
160 path: '/api/app/us-app-auth/my-locations', 64 path: '/api/app/us-app-auth/my-locations',
161 method: 'GET', 65 method: 'GET',
162 auth: true, 66 auth: true,
美国版/Food Labeling Management App UniApp/src/types/usAppBound.ts 0 → 100644
  1 +/** 与后端 UsAppBoundLocationDto 对齐 */
  2 +export interface UsAppBoundLocationDto {
  3 + id: string
  4 + locationCode: string
  5 + locationName: string
  6 + fullAddress: string
  7 + state: boolean
  8 +}
美国版/Food Labeling Management App UniApp/src/utils/authSession.ts
1 -import type { UsAppBoundLocationDto } from '../services/usAppAuth' 1 +import type { UsAppBoundLocationDto } from '../types/usAppBound'
2 2
3 const KEY_ACCESS = 'access_token' 3 const KEY_ACCESS = 'access_token'
4 const KEY_REFRESH = 'refresh_token' 4 const KEY_REFRESH = 'refresh_token'
美国版/Food Labeling Management App UniApp/src/utils/stores.ts
1 -import type { UsAppBoundLocationDto } from '../services/usAppAuth' 1 +import type { UsAppBoundLocationDto } from '../types/usAppBound'
2 import { getBoundLocations } from './authSession' 2 import { getBoundLocations } from './authSession'
3 3
4 export type StoreInfo = UsAppBoundLocationDto 4 export type StoreInfo = UsAppBoundLocationDto
美国版/Food Labeling Management App UniApp/src/utils/usAppApiRequest.ts 0 → 100644
  1 +import { buildApiUrl } from './apiBase'
  2 +import { clearAuthSession } from './authSession'
  3 +
  4 +const SESSION_EXPIRED_TOAST = 'Session expired. Please sign in again.'
  5 +const LOGIN_PATH = '/pages/login/login'
  6 +
  7 +let sessionExpiredHandling = false
  8 +
  9 +/** 已触发登出跳转时抛出,调用方可忽略二次 Toast */
  10 +export class UsAppSessionExpiredError extends Error {
  11 + constructor(message = 'Unauthorized') {
  12 + super(message)
  13 + this.name = 'UsAppSessionExpiredError'
  14 + }
  15 +}
  16 +
  17 +export function isUsAppSessionExpiredError(e: unknown): e is UsAppSessionExpiredError {
  18 + return e instanceof UsAppSessionExpiredError
  19 +}
  20 +
  21 +function handleSessionExpiredAndGoLogin(): void {
  22 + if (sessionExpiredHandling) return
  23 + sessionExpiredHandling = true
  24 + clearAuthSession()
  25 + uni.showToast({
  26 + title: SESSION_EXPIRED_TOAST,
  27 + icon: 'none',
  28 + duration: 2500,
  29 + })
  30 + setTimeout(() => {
  31 + sessionExpiredHandling = false
  32 + uni.reLaunch({ url: LOGIN_PATH })
  33 + }, 400)
  34 +}
  35 +
  36 +/**
  37 + * 取出真实业务负载:支持 ABP `result`、以及统一包装 `{ succeeded, data }`
  38 + */
  39 +export function unwrapApiPayload<T>(data: unknown): T {
  40 + if (data == null || typeof data !== 'object') return data as T
  41 + const o = data as Record<string, unknown>
  42 + if ('result' in o && o.result !== undefined) {
  43 + return o.result as T
  44 + }
  45 + const payload = o.data ?? o.Data
  46 + if (payload !== undefined && payload !== null) {
  47 + return payload as T
  48 + }
  49 + return data as T
  50 +}
  51 +
  52 +export function parseApiErrorMessage(data: unknown): string {
  53 + if (data == null) return 'Request failed'
  54 + if (typeof data === 'string') return data
  55 + if (typeof data === 'object') {
  56 + const o = data as Record<string, unknown>
  57 + const errorsRaw = o.errors ?? o.Errors
  58 + if (typeof errorsRaw === 'string' && errorsRaw.trim()) return errorsRaw.trim()
  59 + if (Array.isArray(errorsRaw)) {
  60 + const parts = errorsRaw.map((x) => String(x)).filter(Boolean)
  61 + if (parts.length) return parts.join('; ')
  62 + }
  63 + const err = o.error as Record<string, unknown> | undefined
  64 + if (err && typeof err.message === 'string') return err.message
  65 + if (typeof o.message === 'string') return o.message
  66 + if (typeof o.error_description === 'string') return o.error_description
  67 + }
  68 + return 'Request failed'
  69 +}
  70 +
  71 +function isBusinessFailurePayload(data: unknown): boolean {
  72 + if (data == null || typeof data !== 'object') return false
  73 + const o = data as Record<string, unknown>
  74 + if (o.succeeded === false || o.Succeeded === false) return true
  75 + const inner = o.statusCode ?? o.StatusCode
  76 + if (typeof inner === 'number' && inner >= 400) return true
  77 + return false
  78 +}
  79 +
  80 +/** HTTP 200 但 JSON 内 statusCode 为 401(少数网关/包装) */
  81 +function isBodyUnauthorized(data: unknown): boolean {
  82 + if (data == null || typeof data !== 'object') return false
  83 + const o = data as Record<string, unknown>
  84 + const inner = o.statusCode ?? o.StatusCode
  85 + return inner === 401
  86 +}
  87 +
  88 +export type UsAppApiRequestOptions = {
  89 + path: string
  90 + method: 'GET' | 'POST'
  91 + data?: unknown
  92 + auth?: boolean
  93 + /** 为 true 时收到 401 不清理会话、不跳转(用于登录等匿名接口) */
  94 + skipUnauthorizedRedirect?: boolean
  95 +}
  96 +
  97 +/**
  98 + * 美国版 App 统一请求:401 时 Toast(英文)+ 清会话 + 回登录页
  99 + */
  100 +export function usAppApiRequest<T>(options: UsAppApiRequestOptions): Promise<T> {
  101 + const header: Record<string, string> = {
  102 + 'Content-Type': 'application/json',
  103 + Accept: 'application/json',
  104 + }
  105 + if (options.auth) {
  106 + const token = uni.getStorageSync('access_token')
  107 + if (token) header.Authorization = `Bearer ${token}`
  108 + }
  109 +
  110 + const skipRedirect = !!options.skipUnauthorizedRedirect
  111 +
  112 + return new Promise((resolve, reject) => {
  113 + uni.request({
  114 + url: buildApiUrl(options.path),
  115 + method: options.method,
  116 + data: options.data,
  117 + header,
  118 + success: (res) => {
  119 + const status = res.statusCode ?? 0
  120 +
  121 + if (status === 401) {
  122 + if (!skipRedirect) {
  123 + handleSessionExpiredAndGoLogin()
  124 + reject(new UsAppSessionExpiredError(parseApiErrorMessage(res.data) || 'Unauthorized'))
  125 + } else {
  126 + reject(new Error(parseApiErrorMessage(res.data) || 'Unauthorized'))
  127 + }
  128 + return
  129 + }
  130 +
  131 + if (status >= 400) {
  132 + reject(new Error(parseApiErrorMessage(res.data)))
  133 + return
  134 + }
  135 +
  136 + if (!skipRedirect && isBodyUnauthorized(res.data)) {
  137 + handleSessionExpiredAndGoLogin()
  138 + reject(new UsAppSessionExpiredError(parseApiErrorMessage(res.data) || 'Unauthorized'))
  139 + return
  140 + }
  141 +
  142 + if (isBusinessFailurePayload(res.data)) {
  143 + reject(new Error(parseApiErrorMessage(res.data)))
  144 + return
  145 + }
  146 +
  147 + try {
  148 + const body = unwrapApiPayload<T>(res.data as unknown)
  149 + resolve(body)
  150 + } catch {
  151 + reject(new Error('Invalid response'))
  152 + }
  153 + },
  154 + fail: (err) => {
  155 + reject(new Error(err.errMsg || 'Network error'))
  156 + },
  157 + })
  158 + })
  159 +}
美国版/Food Labeling Management Platform/build/assets/index-1_5dt1NK.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-C_mEdGxy.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
@@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
5 <meta charset="UTF-8" /> 5 <meta charset="UTF-8" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <title>Food Labeling Management Platform</title> 7 <title>Food Labeling Management Platform</title>
8 - <script type="module" crossorigin src="/assets/index-1_5dt1NK.js"></script> 8 + <script type="module" crossorigin src="/assets/index-C_mEdGxy.js"></script>
9 <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css"> 9 <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css">
10 </head> 10 </head>
11 11
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
@@ -25,9 +25,10 @@ import { @@ -25,9 +25,10 @@ import {
25 DialogTitle, 25 DialogTitle,
26 } from "../ui/dialog"; 26 } from "../ui/dialog";
27 import { Label } from "../ui/label"; 27 import { Label } from "../ui/label";
  28 +import { ImageUrlUpload } from "../ui/image-url-upload";
28 import { Switch } from "../ui/switch"; 29 import { Switch } from "../ui/switch";
29 import { Badge } from "../ui/badge"; 30 import { Badge } from "../ui/badge";
30 -import { Plus, Edit, MoreHorizontal } from "lucide-react"; 31 +import { Plus, Edit, MoreHorizontal, Trash2 } from "lucide-react";
31 import { toast } from "sonner"; 32 import { toast } from "sonner";
32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 33 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 import { 34 import {
@@ -45,6 +46,7 @@ import { @@ -45,6 +46,7 @@ import {
45 updateLabelCategory, 46 updateLabelCategory,
46 deleteLabelCategory, 47 deleteLabelCategory,
47 } from "../../services/labelCategoryService"; 48 } from "../../services/labelCategoryService";
  49 +import { resolvePictureUrlForDisplay } from "../../services/imageUploadService";
48 import type { 50 import type {
49 LabelCategoryDto, 51 LabelCategoryDto,
50 LabelCategoryCreateInput, 52 LabelCategoryCreateInput,
@@ -209,7 +211,17 @@ export function LabelCategoriesView() { @@ -209,7 +211,17 @@ export function LabelCategoriesView() {
209 <TableRow key={item.id} className="hover:bg-gray-50"> 211 <TableRow key={item.id} className="hover:bg-gray-50">
210 <TableCell className="font-medium">{toDisplay(item.categoryName)}</TableCell> 212 <TableCell className="font-medium">{toDisplay(item.categoryName)}</TableCell>
211 <TableCell className="text-gray-600">{toDisplay(item.categoryCode)}</TableCell> 213 <TableCell className="text-gray-600">{toDisplay(item.categoryCode)}</TableCell>
212 - <TableCell className="text-gray-500">{toDisplay(item.categoryPhotoUrl)}</TableCell> 214 + <TableCell>
  215 + {item.categoryPhotoUrl?.trim() ? (
  216 + <img
  217 + src={resolvePictureUrlForDisplay(item.categoryPhotoUrl)}
  218 + alt=""
  219 + className="w-9 h-9 rounded object-cover border border-gray-200"
  220 + />
  221 + ) : (
  222 + <span className="text-gray-400 text-sm">—</span>
  223 + )}
  224 + </TableCell>
213 <TableCell> 225 <TableCell>
214 <Badge className={item.state ? "bg-green-600" : "bg-gray-400"}> 226 <Badge className={item.state ? "bg-green-600" : "bg-gray-400"}>
215 {item.state ? "Active" : "Inactive"} 227 {item.state ? "Active" : "Inactive"}
@@ -248,9 +260,10 @@ export function LabelCategoriesView() { @@ -248,9 +260,10 @@ export function LabelCategoriesView() {
248 <Button 260 <Button
249 type="button" 261 type="button"
250 variant="ghost" 262 variant="ghost"
251 - className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" 263 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
252 onClick={() => openDelete(item)} 264 onClick={() => openDelete(item)}
253 > 265 >
  266 + <Trash2 className="w-4 h-4 shrink-0" />
254 Delete 267 Delete
255 </Button> 268 </Button>
256 </PopoverContent> 269 </PopoverContent>
@@ -451,11 +464,13 @@ function CreateLabelCategoryDialog({ @@ -451,11 +464,13 @@ function CreateLabelCategoryDialog({
451 </div> 464 </div>
452 465
453 <div className="space-y-2"> 466 <div className="space-y-2">
454 - <Label>Category Photo URL</Label>  
455 - <Input  
456 - placeholder="https://cdn.example.com/cat-prep.png" 467 + <Label>Category photo</Label>
  468 + <ImageUrlUpload
457 value={form.categoryPhotoUrl ?? ""} 469 value={form.categoryPhotoUrl ?? ""}
458 - onChange={(e) => setForm((p) => ({ ...p, categoryPhotoUrl: e.target.value || null }))} 470 + onChange={(url) => setForm((p) => ({ ...p, categoryPhotoUrl: url || null }))}
  471 + uploadSubDir="category"
  472 + oneImageOnly
  473 + hint="JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
459 /> 474 />
460 </div> 475 </div>
461 476
@@ -583,11 +598,13 @@ function EditLabelCategoryDialog({ @@ -583,11 +598,13 @@ function EditLabelCategoryDialog({
583 </div> 598 </div>
584 599
585 <div className="space-y-2"> 600 <div className="space-y-2">
586 - <Label>Category Photo URL</Label>  
587 - <Input  
588 - placeholder="https://cdn.example.com/cat-prep.png" 601 + <Label>Category photo</Label>
  602 + <ImageUrlUpload
589 value={form.categoryPhotoUrl ?? ""} 603 value={form.categoryPhotoUrl ?? ""}
590 - onChange={(e) => setForm((p) => ({ ...p, categoryPhotoUrl: e.target.value || null }))} 604 + onChange={(url) => setForm((p) => ({ ...p, categoryPhotoUrl: url || null }))}
  605 + uploadSubDir="category"
  606 + oneImageOnly
  607 + hint="JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
591 /> 608 />
592 </div> 609 </div>
593 610
@@ -675,11 +692,12 @@ function DeleteLabelCategoryDialog({ @@ -675,11 +692,12 @@ function DeleteLabelCategoryDialog({
675 Cancel 692 Cancel
676 </Button> 693 </Button>
677 <Button 694 <Button
678 - className="min-w-24" 695 + className="min-w-24 gap-2"
679 variant="destructive" 696 variant="destructive"
680 disabled={submitting} 697 disabled={submitting}
681 onClick={submit} 698 onClick={submit}
682 > 699 >
  700 + <Trash2 className="h-4 w-4 shrink-0" />
683 {submitting ? "Deleting..." : "Delete"} 701 {submitting ? "Deleting..." : "Delete"}
684 </Button> 702 </Button>
685 </DialogFooter> 703 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
@@ -4,6 +4,7 @@ import { QRCodeSVG } from &#39;qrcode.react&#39;; @@ -4,6 +4,7 @@ import { QRCodeSVG } from &#39;qrcode.react&#39;;
4 import type { LabelTemplate, LabelElement, ElementType } from '../../../types/labelTemplate'; 4 import type { LabelTemplate, LabelElement, ElementType } from '../../../types/labelTemplate';
5 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate'; 5 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate';
6 import { cn } from '../../ui/utils'; 6 import { cn } from '../../ui/utils';
  7 +import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService';
7 import { 8 import {
8 Select, 9 Select,
9 SelectContent, 10 SelectContent,
@@ -219,7 +220,7 @@ function ElementContent({ el }: { el: LabelElement }) { @@ -219,7 +220,7 @@ function ElementContent({ el }: { el: LabelElement }) {
219 if (src) { 220 if (src) {
220 return ( 221 return (
221 <img 222 <img
222 - src={src} 223 + src={resolvePictureUrlForDisplay(src)}
223 alt="" 224 alt=""
224 className="w-full h-full object-contain" 225 className="w-full h-full object-contain"
225 /> 226 />
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
@@ -24,6 +24,8 @@ import type { LocationDto } from &#39;../../../types/location&#39;; @@ -24,6 +24,8 @@ import type { LocationDto } from &#39;../../../types/location&#39;;
24 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; 24 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption';
25 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; 25 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
26 import { Checkbox } from '../../ui/checkbox'; 26 import { Checkbox } from '../../ui/checkbox';
  27 +import { ImageUrlUpload } from '../../ui/image-url-upload';
  28 +import { Trash2 } from 'lucide-react';
27 29
28 interface PropertiesPanelProps { 30 interface PropertiesPanelProps {
29 template: LabelTemplate; 31 template: LabelTemplate;
@@ -155,9 +157,10 @@ export function PropertiesPanel({ @@ -155,9 +157,10 @@ export function PropertiesPanel({
155 <div className="pt-4 border-t border-gray-100"> 157 <div className="pt-4 border-t border-gray-100">
156 <Button 158 <Button
157 variant="destructive" 159 variant="destructive"
158 - className="w-full" 160 + className="w-full gap-2"
159 onClick={() => onDeleteElement(selectedElement.id)} 161 onClick={() => onDeleteElement(selectedElement.id)}
160 > 162 >
  163 + <Trash2 className="h-4 w-4 shrink-0" />
161 Delete Element 164 Delete Element
162 </Button> 165 </Button>
163 </div> 166 </div>
@@ -556,13 +559,17 @@ function ElementConfigFields({ @@ -556,13 +559,17 @@ function ElementConfigFields({
556 return ( 559 return (
557 <> 560 <>
558 <div> 561 <div>
559 - <Label className="text-xs">Image URL</Label>  
560 - <Input  
561 - value={(cfg.src as string) ?? ''}  
562 - onChange={(e) => update('src', e.target.value)}  
563 - className="h-8 text-sm mt-1"  
564 - placeholder="输入图片URL或路径"  
565 - /> 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>
566 </div> 573 </div>
567 <div> 574 <div>
568 <Label className="text-xs">Scale Mode</Label> 575 <Label className="text-xs">Scale Mode</Label>
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
@@ -24,7 +24,7 @@ import { @@ -24,7 +24,7 @@ import {
24 DialogHeader, 24 DialogHeader,
25 DialogTitle, 25 DialogTitle,
26 } from '../ui/dialog'; 26 } from '../ui/dialog';
27 -import { Plus, Pencil, MoreHorizontal } from 'lucide-react'; 27 +import { Plus, Pencil, MoreHorizontal, Trash2 } from 'lucide-react';
28 import { toast } from 'sonner'; 28 import { toast } from 'sonner';
29 import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; 29 import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
30 import { 30 import {
@@ -385,9 +385,10 @@ export function LabelTemplatesView() { @@ -385,9 +385,10 @@ export function LabelTemplatesView() {
385 <Button 385 <Button
386 type="button" 386 type="button"
387 variant="ghost" 387 variant="ghost"
388 - className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" 388 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
389 onClick={() => openDelete(t)} 389 onClick={() => openDelete(t)}
390 > 390 >
  391 + <Trash2 className="w-4 h-4 shrink-0" />
391 Delete 392 Delete
392 </Button> 393 </Button>
393 </PopoverContent> 394 </PopoverContent>
@@ -529,11 +530,12 @@ function DeleteLabelTemplateDialog({ @@ -529,11 +530,12 @@ function DeleteLabelTemplateDialog({
529 Cancel 530 Cancel
530 </Button> 531 </Button>
531 <Button 532 <Button
532 - className="min-w-24" 533 + className="min-w-24 gap-2"
533 variant="destructive" 534 variant="destructive"
534 disabled={submitting} 535 disabled={submitting}
535 onClick={submit} 536 onClick={submit}
536 > 537 >
  538 + <Trash2 className="h-4 w-4 shrink-0" />
537 {submitting ? "Deleting..." : "Delete"} 539 {submitting ? "Deleting..." : "Delete"}
538 </Button> 540 </Button>
539 </DialogFooter> 541 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/labels/LabelTypesView.tsx
@@ -27,7 +27,7 @@ import { @@ -27,7 +27,7 @@ import {
27 import { Label } from "../ui/label"; 27 import { Label } from "../ui/label";
28 import { Switch } from "../ui/switch"; 28 import { Switch } from "../ui/switch";
29 import { Badge } from "../ui/badge"; 29 import { Badge } from "../ui/badge";
30 -import { Plus, Edit, MoreHorizontal } from "lucide-react"; 30 +import { Plus, Edit, MoreHorizontal, Trash2 } from "lucide-react";
31 import { toast } from "sonner"; 31 import { toast } from "sonner";
32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 import { 33 import {
@@ -245,9 +245,10 @@ export function LabelTypesView() { @@ -245,9 +245,10 @@ export function LabelTypesView() {
245 <Button 245 <Button
246 type="button" 246 type="button"
247 variant="ghost" 247 variant="ghost"
248 - className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" 248 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
249 onClick={() => openDelete(item)} 249 onClick={() => openDelete(item)}
250 > 250 >
  251 + <Trash2 className="w-4 h-4 shrink-0" />
251 Delete 252 Delete
252 </Button> 253 </Button>
253 </PopoverContent> 254 </PopoverContent>
@@ -649,11 +650,12 @@ function DeleteLabelTypeDialog({ @@ -649,11 +650,12 @@ function DeleteLabelTypeDialog({
649 Cancel 650 Cancel
650 </Button> 651 </Button>
651 <Button 652 <Button
652 - className="min-w-24" 653 + className="min-w-24 gap-2"
653 variant="destructive" 654 variant="destructive"
654 disabled={submitting} 655 disabled={submitting}
655 onClick={submit} 656 onClick={submit}
656 > 657 >
  658 + <Trash2 className="h-4 w-4 shrink-0" />
657 {submitting ? "Deleting..." : "Delete"} 659 {submitting ? "Deleting..." : "Delete"}
658 </Button> 660 </Button>
659 </DialogFooter> 661 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
@@ -27,7 +27,7 @@ import { @@ -27,7 +27,7 @@ import {
27 import { Label } from "../ui/label"; 27 import { Label } from "../ui/label";
28 import { Switch } from "../ui/switch"; 28 import { Switch } from "../ui/switch";
29 import { Badge } from "../ui/badge"; 29 import { Badge } from "../ui/badge";
30 -import { Plus, Edit, MoreHorizontal, ChevronsUpDown } from "lucide-react"; 30 +import { Plus, Edit, MoreHorizontal, ChevronsUpDown, Trash2 } from "lucide-react";
31 import { toast } from "sonner"; 31 import { toast } from "sonner";
32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 import { Checkbox } from "../ui/checkbox"; 33 import { Checkbox } from "../ui/checkbox";
@@ -72,8 +72,10 @@ function labelRowCode(item: LabelDto): string { @@ -72,8 +72,10 @@ function labelRowCode(item: LabelDto): string {
72 return c || "None"; 72 return c || "None";
73 } 73 }
74 74
75 -/** 列表行:产品列(优先展示名称,否则展示绑定数量) */ 75 +/** 列表行:产品列(接口可能返回 `products` 汇总字符串或 `productName` / productIds) */
76 function labelRowProductsText(item: LabelDto): string { 76 function labelRowProductsText(item: LabelDto): string {
  77 + const aggregated = (item.products ?? "").trim();
  78 + if (aggregated) return aggregated;
77 const pn = (item.productName ?? "").trim(); 79 const pn = (item.productName ?? "").trim();
78 if (pn) return pn; 80 if (pn) return pn;
79 const n = item.productIds?.length ?? 0; 81 const n = item.productIds?.length ?? 0;
@@ -523,9 +525,10 @@ export function LabelsList() { @@ -523,9 +525,10 @@ export function LabelsList() {
523 <Button 525 <Button
524 type="button" 526 type="button"
525 variant="ghost" 527 variant="ghost"
526 - className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" 528 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
527 onClick={() => openDelete(item)} 529 onClick={() => openDelete(item)}
528 > 530 >
  531 + <Trash2 className="w-4 h-4 shrink-0" />
529 Delete 532 Delete
530 </Button> 533 </Button>
531 </PopoverContent> 534 </PopoverContent>
@@ -1165,11 +1168,12 @@ function DeleteLabelDialog({ @@ -1165,11 +1168,12 @@ function DeleteLabelDialog({
1165 Cancel 1168 Cancel
1166 </Button> 1169 </Button>
1167 <Button 1170 <Button
1168 - className="min-w-24" 1171 + className="min-w-24 gap-2"
1169 variant="destructive" 1172 variant="destructive"
1170 disabled={submitting} 1173 disabled={submitting}
1171 onClick={submit} 1174 onClick={submit}
1172 > 1175 >
  1176 + <Trash2 className="h-4 w-4 shrink-0" />
1173 {submitting ? "Deleting..." : "Delete"} 1177 {submitting ? "Deleting..." : "Delete"}
1174 </Button> 1178 </Button>
1175 </DialogFooter> 1179 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/labels/MultipleOptionsView.tsx
@@ -27,7 +27,7 @@ import { @@ -27,7 +27,7 @@ import {
27 import { Label } from "../ui/label"; 27 import { Label } from "../ui/label";
28 import { Switch } from "../ui/switch"; 28 import { Switch } from "../ui/switch";
29 import { Badge } from "../ui/badge"; 29 import { Badge } from "../ui/badge";
30 -import { Plus, Edit, MoreHorizontal, X } from "lucide-react"; 30 +import { Plus, Edit, MoreHorizontal, X, Trash2 } from "lucide-react";
31 import { toast } from "sonner"; 31 import { toast } from "sonner";
32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 import { 33 import {
@@ -251,9 +251,10 @@ export function MultipleOptionsView() { @@ -251,9 +251,10 @@ export function MultipleOptionsView() {
251 <Button 251 <Button
252 type="button" 252 type="button"
253 variant="ghost" 253 variant="ghost"
254 - className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" 254 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
255 onClick={() => openDelete(item)} 255 onClick={() => openDelete(item)}
256 > 256 >
  257 + <Trash2 className="w-4 h-4 shrink-0" />
257 Delete 258 Delete
258 </Button> 259 </Button>
259 </PopoverContent> 260 </PopoverContent>
@@ -793,11 +794,12 @@ function DeleteMultipleOptionDialog({ @@ -793,11 +794,12 @@ function DeleteMultipleOptionDialog({
793 Cancel 794 Cancel
794 </Button> 795 </Button>
795 <Button 796 <Button
796 - className="min-w-24" 797 + className="min-w-24 gap-2"
797 variant="destructive" 798 variant="destructive"
798 disabled={submitting} 799 disabled={submitting}
799 onClick={submit} 800 onClick={submit}
800 > 801 >
  802 + <Trash2 className="h-4 w-4 shrink-0" />
801 {submitting ? "Deleting..." : "Delete"} 803 {submitting ? "Deleting..." : "Delete"}
802 </Button> 804 </Button>
803 </DialogFooter> 805 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
1 import React, { useEffect, useMemo, useRef, useState } from "react"; 1 import React, { useEffect, useMemo, useRef, useState } from "react";
2 -import { Edit, MapPin, MoreHorizontal } from "lucide-react"; 2 +import { Edit, MapPin, MoreHorizontal, Trash2 } from "lucide-react";
3 import { Button } from "../ui/button"; 3 import { Button } from "../ui/button";
4 import { Input } from "../ui/input"; 4 import { Input } from "../ui/input";
5 import { 5 import {
@@ -373,9 +373,10 @@ export function LocationsView() { @@ -373,9 +373,10 @@ export function LocationsView() {
373 <Button 373 <Button
374 type="button" 374 type="button"
375 variant="ghost" 375 variant="ghost"
376 - className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" 376 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
377 onClick={() => openDelete(loc)} 377 onClick={() => openDelete(loc)}
378 > 378 >
  379 + <Trash2 className="w-4 h-4 shrink-0" />
379 Delete 380 Delete
380 </Button> 381 </Button>
381 </PopoverContent> 382 </PopoverContent>
@@ -1067,11 +1068,12 @@ function DeleteLocationDialog({ @@ -1067,11 +1068,12 @@ function DeleteLocationDialog({
1067 <DialogFooter className="flex-row flex-wrap justify-end"> 1068 <DialogFooter className="flex-row flex-wrap justify-end">
1068 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button> 1069 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
1069 <Button 1070 <Button
1070 - className="min-w-24" 1071 + className="min-w-24 gap-2"
1071 variant="destructive" 1072 variant="destructive"
1072 disabled={submitting} 1073 disabled={submitting}
1073 onClick={submit} 1074 onClick={submit}
1074 > 1075 >
  1076 + <Trash2 className="h-4 w-4 shrink-0" />
1075 {submitting ? "Deleting..." : "Delete"} 1077 {submitting ? "Deleting..." : "Delete"}
1076 </Button> 1078 </Button>
1077 </DialogFooter> 1079 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/menus/MenuManagementView.tsx
@@ -16,6 +16,13 @@ import { @@ -16,6 +16,13 @@ import {
16 } from "../ui/dialog"; 16 } from "../ui/dialog";
17 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 17 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
18 import { 18 import {
  19 + Select,
  20 + SelectContent,
  21 + SelectItem,
  22 + SelectTrigger,
  23 + SelectValue,
  24 +} from "../ui/select";
  25 +import {
19 Table, 26 Table,
20 TableBody, 27 TableBody,
21 TableCell, 28 TableCell,
@@ -67,7 +74,7 @@ export function MenuManagementView() { @@ -67,7 +74,7 @@ export function MenuManagementView() {
67 const [debouncedKeyword, setDebouncedKeyword] = useState(""); 74 const [debouncedKeyword, setDebouncedKeyword] = useState("");
68 75
69 const [pageIndex, setPageIndex] = useState(1); 76 const [pageIndex, setPageIndex] = useState(1);
70 - const [pageSize] = useState(10); 77 + const [pageSize, setPageSize] = useState(10);
71 78
72 const [isCreateOpen, setIsCreateOpen] = useState(false); 79 const [isCreateOpen, setIsCreateOpen] = useState(false);
73 const [isEditOpen, setIsEditOpen] = useState(false); 80 const [isEditOpen, setIsEditOpen] = useState(false);
@@ -89,7 +96,18 @@ export function MenuManagementView() { @@ -89,7 +96,18 @@ export function MenuManagementView() {
89 setPageIndex(1); 96 setPageIndex(1);
90 }, [debouncedKeyword]); 97 }, [debouncedKeyword]);
91 98
92 - const totalPages = Math.max(1, Math.ceil(total / pageSize)); 99 + useEffect(() => {
  100 + setPageIndex(1);
  101 + }, [pageSize]);
  102 +
  103 + const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
  104 +
  105 + useEffect(() => {
  106 + setPageIndex((p) => {
  107 + const tp = Math.max(1, Math.ceil(total / pageSize) || 1);
  108 + return p > tp ? tp : p;
  109 + });
  110 + }, [total, pageSize]);
93 111
94 useEffect(() => { 112 useEffect(() => {
95 const run = async () => { 113 const run = async () => {
@@ -238,50 +256,60 @@ export function MenuManagementView() { @@ -238,50 +256,60 @@ export function MenuManagementView() {
238 </Table> 256 </Table>
239 </div> 257 </div>
240 258
241 - <div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between"> 259 + <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3">
242 <div className="text-sm text-gray-600"> 260 <div className="text-sm text-gray-600">
243 - {total === 0 ? "0 results" : `${total} results`} 261 + Showing {total === 0 ? 0 : (pageIndex - 1) * pageSize + 1}-
  262 + {Math.min(pageIndex * pageSize, total)} of {total}
244 </div> 263 </div>
245 264
246 - <Pagination>  
247 - <PaginationContent>  
248 - <PaginationItem>  
249 - <PaginationPrevious  
250 - href="#"  
251 - onClick={(e) => {  
252 - e.preventDefault();  
253 - setPageIndex((p) => Math.max(1, p - 1));  
254 - }}  
255 - />  
256 - </PaginationItem>  
257 - {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {  
258 - const page = i + 1;  
259 - return (  
260 - <PaginationItem key={page}>  
261 - <PaginationLink  
262 - href="#"  
263 - isActive={pageIndex === page}  
264 - onClick={(e) => {  
265 - e.preventDefault();  
266 - setPageIndex(page);  
267 - }}  
268 - >  
269 - {page}  
270 - </PaginationLink>  
271 - </PaginationItem>  
272 - );  
273 - })}  
274 - <PaginationItem>  
275 - <PaginationNext  
276 - href="#"  
277 - onClick={(e) => {  
278 - e.preventDefault();  
279 - setPageIndex((p) => Math.min(totalPages, p + 1));  
280 - }}  
281 - />  
282 - </PaginationItem>  
283 - </PaginationContent>  
284 - </Pagination> 265 + <div className="flex items-center gap-3">
  266 + <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
  267 + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900">
  268 + <SelectValue />
  269 + </SelectTrigger>
  270 + <SelectContent>
  271 + {[10, 20, 50].map((n) => (
  272 + <SelectItem key={n} value={String(n)}>
  273 + {n} / page
  274 + </SelectItem>
  275 + ))}
  276 + </SelectContent>
  277 + </Select>
  278 +
  279 + <Pagination className="mx-0 w-auto justify-end">
  280 + <PaginationContent>
  281 + <PaginationItem>
  282 + <PaginationPrevious
  283 + href="#"
  284 + size="default"
  285 + onClick={(e) => {
  286 + e.preventDefault();
  287 + setPageIndex((p) => Math.max(1, p - 1));
  288 + }}
  289 + aria-disabled={pageIndex <= 1}
  290 + className={pageIndex <= 1 ? "pointer-events-none opacity-50" : ""}
  291 + />
  292 + </PaginationItem>
  293 + <PaginationItem>
  294 + <PaginationLink href="#" isActive size="default" onClick={(e) => e.preventDefault()}>
  295 + Page {pageIndex} / {totalPages}
  296 + </PaginationLink>
  297 + </PaginationItem>
  298 + <PaginationItem>
  299 + <PaginationNext
  300 + href="#"
  301 + size="default"
  302 + onClick={(e) => {
  303 + e.preventDefault();
  304 + setPageIndex((p) => Math.min(totalPages, p + 1));
  305 + }}
  306 + aria-disabled={pageIndex >= totalPages}
  307 + className={pageIndex >= totalPages ? "pointer-events-none opacity-50" : ""}
  308 + />
  309 + </PaginationItem>
  310 + </PaginationContent>
  311 + </Pagination>
  312 + </div>
285 </div> 313 </div>
286 </div> 314 </div>
287 315
@@ -497,7 +525,8 @@ function DeleteMenuDialog({ @@ -497,7 +525,8 @@ function DeleteMenuDialog({
497 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> 525 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
498 Cancel 526 Cancel
499 </Button> 527 </Button>
500 - <Button className="min-w-24" variant="destructive" disabled={submitting} onClick={submit}> 528 + <Button className="min-w-24 gap-2" variant="destructive" disabled={submitting} onClick={submit}>
  529 + <Trash2 className="h-4 w-4 shrink-0" />
501 {submitting ? "Deleting..." : "Delete"} 530 {submitting ? "Deleting..." : "Delete"}
502 </Button> 531 </Button>
503 </DialogFooter> 532 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
@@ -1190,7 +1190,13 @@ function RoleMenuPermissionsDialog({ @@ -1190,7 +1190,13 @@ function RoleMenuPermissionsDialog({
1190 <Button variant="outline" onClick={() => onOpenChange(false)}> 1190 <Button variant="outline" onClick={() => onOpenChange(false)}>
1191 Cancel 1191 Cancel
1192 </Button> 1192 </Button>
1193 - <Button variant="destructive" disabled={submitting || selectedIds.size === 0 || !roleId} onClick={clearAll}> 1193 + <Button
  1194 + variant="destructive"
  1195 + className="gap-2"
  1196 + disabled={submitting || selectedIds.size === 0 || !roleId}
  1197 + onClick={clearAll}
  1198 + >
  1199 + <Trash2 className="h-4 w-4 shrink-0" />
1194 Delete Selected 1200 Delete Selected
1195 </Button> 1201 </Button>
1196 <Button disabled={submitting || !roleId} onClick={submit} className="bg-blue-600 text-white hover:bg-blue-700"> 1202 <Button disabled={submitting || !roleId} onClick={submit} className="bg-blue-600 text-white hover:bg-blue-700">
@@ -1257,11 +1263,12 @@ function DeleteRoleDialog({ @@ -1257,11 +1263,12 @@ function DeleteRoleDialog({
1257 Cancel 1263 Cancel
1258 </Button> 1264 </Button>
1259 <Button 1265 <Button
1260 - className="min-w-24" 1266 + className="min-w-24 gap-2"
1261 variant="destructive" 1267 variant="destructive"
1262 disabled={submitting} 1268 disabled={submitting}
1263 onClick={submit} 1269 onClick={submit}
1264 > 1270 >
  1271 + <Trash2 className="h-4 w-4 shrink-0" />
1265 {submitting ? "Deleting..." : "Delete"} 1272 {submitting ? "Deleting..." : "Delete"}
1266 </Button> 1273 </Button>
1267 </DialogFooter> 1274 </DialogFooter>
@@ -1805,10 +1812,11 @@ function DeleteMemberDialog({ @@ -1805,10 +1812,11 @@ function DeleteMemberDialog({
1805 </Button> 1812 </Button>
1806 <Button 1813 <Button
1807 variant="destructive" 1814 variant="destructive"
1808 - className="min-w-24" 1815 + className="min-w-24 gap-2"
1809 disabled={submitting} 1816 disabled={submitting}
1810 onClick={submit} 1817 onClick={submit}
1811 > 1818 >
  1819 + <Trash2 className="h-4 w-4 shrink-0" />
1812 {submitting ? "Deleting..." : "Delete"} 1820 {submitting ? "Deleting..." : "Delete"}
1813 </Button> 1821 </Button>
1814 </DialogFooter> 1822 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
1 import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; 1 import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 -import { Search, Plus, Download, Upload, Edit, MoreHorizontal, Image as ImageIcon, Package } from "lucide-react"; 2 +import {
  3 + Search,
  4 + Plus,
  5 + Download,
  6 + Upload,
  7 + Edit,
  8 + MoreHorizontal,
  9 + Package,
  10 + Trash2,
  11 +} from "lucide-react";
3 import { Button } from "../ui/button"; 12 import { Button } from "../ui/button";
4 import { Input } from "../ui/input"; 13 import { Input } from "../ui/input";
5 import { 14 import {
@@ -26,12 +35,20 @@ import { @@ -26,12 +35,20 @@ import {
26 SelectValue, 35 SelectValue,
27 } from "../ui/select"; 36 } from "../ui/select";
28 import { Label } from "../ui/label"; 37 import { Label } from "../ui/label";
  38 +import { ImageUrlUpload } from "../ui/image-url-upload";
  39 +import { resolvePictureUrlForDisplay } from "../../services/imageUploadService";
29 import { Switch } from "../ui/switch"; 40 import { Switch } from "../ui/switch";
30 import { Badge } from "../ui/badge"; 41 import { Badge } from "../ui/badge";
31 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; 42 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
32 import { toast } from "sonner"; 43 import { toast } from "sonner";
33 import { getLocations } from "../../services/locationService"; 44 import { getLocations } from "../../services/locationService";
34 -import { getLabelCategories } from "../../services/labelCategoryService"; 45 +import {
  46 + createProductCategory,
  47 + deleteProductCategory,
  48 + getProductCategories,
  49 + getProductCategory,
  50 + updateProductCategory,
  51 +} from "../../services/productCategoryService";
35 import { 52 import {
36 createProduct, 53 createProduct,
37 deleteProduct, 54 deleteProduct,
@@ -46,9 +63,17 @@ import { @@ -46,9 +63,17 @@ import {
46 updateProductLocation, 63 updateProductLocation,
47 } from "../../services/productLocationService"; 64 } from "../../services/productLocationService";
48 import type { LocationDto } from "../../types/location"; 65 import type { LocationDto } from "../../types/location";
49 -import type { LabelCategoryDto } from "../../types/labelCategory";  
50 import type { ProductDto, ProductCreateInput, ProductUpdateInput } from "../../types/product"; 66 import type { ProductDto, ProductCreateInput, ProductUpdateInput } from "../../types/product";
  67 +import type { ProductCategoryDto, ProductCategoryCreateInput } from "../../types/productCategory";
51 import { SearchableSelect } from "../ui/searchable-select"; 68 import { SearchableSelect } from "../ui/searchable-select";
  69 +import {
  70 + Pagination,
  71 + PaginationContent,
  72 + PaginationItem,
  73 + PaginationLink,
  74 + PaginationNext,
  75 + PaginationPrevious,
  76 +} from "../ui/pagination";
52 77
53 function toDisplay(v: string | null | undefined): string { 78 function toDisplay(v: string | null | undefined): string {
54 const s = (v ?? "").trim(); 79 const s = (v ?? "").trim();
@@ -101,7 +126,17 @@ export function ProductsView() { @@ -101,7 +126,17 @@ export function ProductsView() {
101 const [loading, setLoading] = useState(false); 126 const [loading, setLoading] = useState(false);
102 const [locationMap, setLocationMap] = useState<Map<string, string[]>>(new Map()); 127 const [locationMap, setLocationMap] = useState<Map<string, string[]>>(new Map());
103 const [locations, setLocations] = useState<LocationDto[]>([]); 128 const [locations, setLocations] = useState<LocationDto[]>([]);
104 - const [labelCategories, setLabelCategories] = useState<LabelCategoryDto[]>([]); 129 + /** 产品分类全量(筛选项、产品表单下拉),与 Categories 标签 API 一致 */
  130 + const [productCategoriesCatalog, setProductCategoriesCatalog] = useState<ProductCategoryDto[]>([]);
  131 + const [catalogReloadToken, setCatalogReloadToken] = useState(0);
  132 +
  133 + const [productCategoryRows, setProductCategoryRows] = useState<ProductCategoryDto[]>([]);
  134 + const [catTotal, setCatTotal] = useState(0);
  135 + const [catLoading, setCatLoading] = useState(false);
  136 + const [catPageIndex, setCatPageIndex] = useState(1);
  137 + const [catPageSize, setCatPageSize] = useState(10);
  138 + const [catRefreshSeq, setCatRefreshSeq] = useState(0);
  139 + const catAbortRef = useRef<AbortController | null>(null);
105 140
106 const [keyword, setKeyword] = useState(""); 141 const [keyword, setKeyword] = useState("");
107 const [debouncedKeyword, setDebouncedKeyword] = useState(""); 142 const [debouncedKeyword, setDebouncedKeyword] = useState("");
@@ -116,7 +151,10 @@ export function ProductsView() { @@ -116,7 +151,10 @@ export function ProductsView() {
116 const abortRef = useRef<AbortController | null>(null); 151 const abortRef = useRef<AbortController | null>(null);
117 152
118 const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); 153 const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
119 - const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false); 154 + const [isProductCategoryDialogOpen, setIsProductCategoryDialogOpen] = useState(false);
  155 + const [editingProductCategory, setEditingProductCategory] = useState<ProductCategoryDto | null>(null);
  156 + const [deletingProductCategory, setDeletingProductCategory] = useState<ProductCategoryDto | null>(null);
  157 + const [categoriesActionsOpenId, setCategoriesActionsOpenId] = useState<string | null>(null);
120 const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null); 158 const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null);
121 const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null); 159 const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null);
122 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null); 160 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null);
@@ -135,27 +173,37 @@ export function ProductsView() { @@ -135,27 +173,37 @@ export function ProductsView() {
135 try { 173 try {
136 const [locRes, catRes] = await Promise.all([ 174 const [locRes, catRes] = await Promise.all([
137 getLocations({ skipCount: 0, maxResultCount: 500 }), 175 getLocations({ skipCount: 0, maxResultCount: 500 }),
138 - getLabelCategories({ skipCount: 0, maxResultCount: 500 }), 176 + getProductCategories({
  177 + skipCount: 0,
  178 + maxResultCount: 500,
  179 + sorting: "OrderNum desc",
  180 + }),
139 ]); 181 ]);
140 if (c) return; 182 if (c) return;
141 setLocations(locRes.items ?? []); 183 setLocations(locRes.items ?? []);
142 - setLabelCategories(catRes.items ?? []); 184 + setProductCategoriesCatalog(catRes.items ?? []);
143 } catch { 185 } catch {
144 if (!c) { 186 if (!c) {
145 setLocations([]); 187 setLocations([]);
146 - setLabelCategories([]); 188 + setProductCategoriesCatalog([]);
147 } 189 }
148 } 190 }
149 })(); 191 })();
150 return () => { 192 return () => {
151 c = true; 193 c = true;
152 }; 194 };
153 - }, []); 195 + }, [catalogReloadToken]);
  196 +
  197 + const reloadCategoryCatalog = () => setCatalogReloadToken((x) => x + 1);
154 198
155 useEffect(() => { 199 useEffect(() => {
156 setPageIndex(1); 200 setPageIndex(1);
157 }, [debouncedKeyword, locationFilter, categoryFilter, stateFilter, pageSize]); 201 }, [debouncedKeyword, locationFilter, categoryFilter, stateFilter, pageSize]);
158 202
  203 + useEffect(() => {
  204 + setCatPageIndex(1);
  205 + }, [debouncedKeyword, stateFilter, catPageSize]);
  206 +
159 const needClientFilter = locationFilter !== "all" || categoryFilter !== "all"; 207 const needClientFilter = locationFilter !== "all" || categoryFilter !== "all";
160 208
161 useEffect(() => { 209 useEffect(() => {
@@ -236,8 +284,62 @@ export function ProductsView() { @@ -236,8 +284,62 @@ export function ProductsView() {
236 needClientFilter, 284 needClientFilter,
237 ]); 285 ]);
238 286
  287 + const catTotalPages = Math.max(1, Math.ceil(catTotal / catPageSize) || 1);
  288 +
  289 + useEffect(() => {
  290 + setCatPageIndex((p) => {
  291 + const tp = Math.max(1, Math.ceil(catTotal / catPageSize) || 1);
  292 + return p > tp ? tp : p;
  293 + });
  294 + }, [catTotal, catPageSize]);
  295 +
  296 + useEffect(() => {
  297 + if (activeTab !== "categories") return;
  298 +
  299 + const run = async () => {
  300 + catAbortRef.current?.abort();
  301 + const ac = new AbortController();
  302 + catAbortRef.current = ac;
  303 +
  304 + setCatLoading(true);
  305 + try {
  306 + const skip = (catPageIndex - 1) * catPageSize;
  307 + const res = await getProductCategories(
  308 + {
  309 + skipCount: skip,
  310 + maxResultCount: catPageSize,
  311 + sorting: "OrderNum desc",
  312 + keyword: debouncedKeyword || undefined,
  313 + state: stateFilter === "all" ? undefined : stateFilter === "true",
  314 + },
  315 + ac.signal,
  316 + );
  317 + if (ac.signal.aborted) return;
  318 + setProductCategoryRows(res.items ?? []);
  319 + setCatTotal(res.totalCount ?? 0);
  320 + } catch (e: any) {
  321 + if (e?.name === "AbortError") return;
  322 + toast.error("Failed to load categories", {
  323 + description: e?.message ? String(e.message) : "Please try again.",
  324 + });
  325 + setProductCategoryRows([]);
  326 + setCatTotal(0);
  327 + } finally {
  328 + if (!ac.signal.aborted) setCatLoading(false);
  329 + }
  330 + };
  331 +
  332 + run();
  333 + return () => catAbortRef.current?.abort();
  334 + }, [activeTab, debouncedKeyword, stateFilter, catPageIndex, catPageSize, catRefreshSeq]);
  335 +
239 const refresh = () => setRefreshSeq((x) => x + 1); 336 const refresh = () => setRefreshSeq((x) => x + 1);
240 337
  338 + const refreshCategories = () => {
  339 + setCatRefreshSeq((x) => x + 1);
  340 + reloadCategoryCatalog();
  341 + };
  342 +
241 const locationOptions = useMemo( 343 const locationOptions = useMemo(
242 () => 344 () =>
243 locations.map((loc) => ({ 345 locations.map((loc) => ({
@@ -249,11 +351,13 @@ export function ProductsView() { @@ -249,11 +351,13 @@ export function ProductsView() {
249 351
250 const categoryNameOptions = useMemo( 352 const categoryNameOptions = useMemo(
251 () => 353 () =>
252 - labelCategories.map((c) => ({  
253 - value: (c.categoryName ?? c.categoryCode ?? c.id ?? "").trim(),  
254 - label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id),  
255 - })).filter((o) => o.value),  
256 - [labelCategories], 354 + productCategoriesCatalog
  355 + .map((c) => ({
  356 + value: (c.categoryName ?? c.categoryCode ?? c.id ?? "").trim(),
  357 + label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id),
  358 + }))
  359 + .filter((o) => o.value),
  360 + [productCategoriesCatalog],
257 ); 361 );
258 362
259 const totalPages = Math.max(1, Math.ceil(total / pageSize)); 363 const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -372,7 +476,10 @@ export function ProductsView() { @@ -372,7 +476,10 @@ export function ProductsView() {
372 ) : ( 476 ) : (
373 <Button 477 <Button
374 className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0" 478 className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"
375 - onClick={() => setIsCategoryDialogOpen(true)} 479 + onClick={() => {
  480 + setEditingProductCategory(null);
  481 + setIsProductCategoryDialogOpen(true);
  482 + }}
376 > 483 >
377 New Category <Plus className="w-4 h-4" /> 484 New Category <Plus className="w-4 h-4" />
378 </Button> 485 </Button>
@@ -465,7 +572,7 @@ export function ProductsView() { @@ -465,7 +572,7 @@ export function ProductsView() {
465 <div className="flex items-center gap-2 min-w-0"> 572 <div className="flex items-center gap-2 min-w-0">
466 {p.productImageUrl ? ( 573 {p.productImageUrl ? (
467 <img 574 <img
468 - src={p.productImageUrl} 575 + src={resolvePictureUrlForDisplay(p.productImageUrl)}
469 alt="" 576 alt=""
470 className="w-8 h-8 rounded object-cover border border-gray-200 shrink-0" 577 className="w-8 h-8 rounded object-cover border border-gray-200 shrink-0"
471 /> 578 />
@@ -523,12 +630,13 @@ export function ProductsView() { @@ -523,12 +630,13 @@ export function ProductsView() {
523 <Button 630 <Button
524 type="button" 631 type="button"
525 variant="ghost" 632 variant="ghost"
526 - className="w-full justify-start h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50" 633 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
527 onClick={() => { 634 onClick={() => {
528 setActionsOpenId(null); 635 setActionsOpenId(null);
529 setDeletingProduct(p); 636 setDeletingProduct(p);
530 }} 637 }}
531 > 638 >
  639 + <Trash2 className="w-4 h-4 shrink-0" />
532 Delete 640 Delete
533 </Button> 641 </Button>
534 </PopoverContent> 642 </PopoverContent>
@@ -583,7 +691,164 @@ export function ProductsView() { @@ -583,7 +691,164 @@ export function ProductsView() {
583 </div> 691 </div>
584 </div> 692 </div>
585 ) : ( 693 ) : (
586 - <CategoriesPlaceholderTab categories={labelCategories} /> 694 + <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden flex flex-col">
  695 + <Table>
  696 + <TableHeader>
  697 + <TableRow className="bg-gray-100 hover:bg-gray-100">
  698 + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Category Name</TableHead>
  699 + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Code</TableHead>
  700 + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Photo</TableHead>
  701 + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Order</TableHead>
  702 + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Status</TableHead>
  703 + <TableHead className="text-gray-900 font-bold border-r whitespace-nowrap">Last edited</TableHead>
  704 + <TableHead className="text-gray-900 font-bold text-center whitespace-nowrap w-[72px]">Actions</TableHead>
  705 + </TableRow>
  706 + </TableHeader>
  707 + <TableBody>
  708 + {catLoading ? (
  709 + <TableRow>
  710 + <TableCell colSpan={7} className="text-center text-gray-500 py-10">
  711 + Loading...
  712 + </TableCell>
  713 + </TableRow>
  714 + ) : productCategoryRows.length === 0 ? (
  715 + <TableRow>
  716 + <TableCell colSpan={7} className="text-center text-gray-500 py-10">
  717 + No categories found.
  718 + </TableCell>
  719 + </TableRow>
  720 + ) : (
  721 + productCategoryRows.map((c) => {
  722 + const active = c.state !== false;
  723 + return (
  724 + <TableRow key={c.id}>
  725 + <TableCell className="border-r font-medium text-gray-900">{toDisplay(c.categoryName)}</TableCell>
  726 + <TableCell className="border-r text-gray-600 font-mono text-sm">{toDisplay(c.categoryCode)}</TableCell>
  727 + <TableCell className="border-r">
  728 + {c.categoryPhotoUrl ? (
  729 + <img
  730 + src={resolvePictureUrlForDisplay(c.categoryPhotoUrl)}
  731 + alt=""
  732 + className="w-9 h-9 rounded object-cover border border-gray-200"
  733 + />
  734 + ) : (
  735 + <span className="text-gray-400 text-sm">—</span>
  736 + )}
  737 + </TableCell>
  738 + <TableCell className="border-r text-gray-700">{c.orderNum ?? "—"}</TableCell>
  739 + <TableCell className="border-r whitespace-nowrap">
  740 + <Badge variant={active ? "default" : "secondary"} className={active ? "bg-green-600" : "bg-gray-400"}>
  741 + {active ? "active" : "inactive"}
  742 + </Badge>
  743 + </TableCell>
  744 + <TableCell className="border-r text-gray-600 text-sm">{toDisplay(c.lastEdited)}</TableCell>
  745 + <TableCell className="text-center whitespace-nowrap">
  746 + <Popover
  747 + open={categoriesActionsOpenId === c.id}
  748 + onOpenChange={(open) => setCategoriesActionsOpenId(open ? c.id : null)}
  749 + >
  750 + <PopoverTrigger asChild>
  751 + <Button type="button" variant="ghost" size="icon" className="h-8 w-8">
  752 + <MoreHorizontal className="h-4 w-4" />
  753 + </Button>
  754 + </PopoverTrigger>
  755 + <PopoverContent align="end" className="w-36 p-1">
  756 + <Button
  757 + type="button"
  758 + variant="ghost"
  759 + className="w-full justify-start h-9 px-2 font-normal"
  760 + onClick={async () => {
  761 + setCategoriesActionsOpenId(null);
  762 + try {
  763 + const fresh = await getProductCategory(c.id);
  764 + setEditingProductCategory(fresh);
  765 + setIsProductCategoryDialogOpen(true);
  766 + } catch (e: any) {
  767 + toast.error("Failed to load category", {
  768 + description: e?.message ? String(e.message) : "",
  769 + });
  770 + }
  771 + }}
  772 + >
  773 + <Edit className="w-4 h-4 mr-2" />
  774 + Edit
  775 + </Button>
  776 + <Button
  777 + type="button"
  778 + variant="ghost"
  779 + className="w-full justify-start gap-2 h-9 px-2 font-normal text-red-600 hover:text-red-700 hover:bg-red-50"
  780 + onClick={() => {
  781 + setCategoriesActionsOpenId(null);
  782 + setDeletingProductCategory(c);
  783 + }}
  784 + >
  785 + <Trash2 className="w-4 h-4 shrink-0" />
  786 + Delete
  787 + </Button>
  788 + </PopoverContent>
  789 + </Popover>
  790 + </TableCell>
  791 + </TableRow>
  792 + );
  793 + })
  794 + )}
  795 + </TableBody>
  796 + </Table>
  797 +
  798 + <div className="px-4 py-3 border-t border-gray-200 bg-white flex flex-wrap items-center justify-between gap-3 shrink-0">
  799 + <div className="text-sm text-gray-600">
  800 + Showing {catTotal === 0 ? 0 : (catPageIndex - 1) * catPageSize + 1}-
  801 + {Math.min(catPageIndex * catPageSize, catTotal)} of {catTotal}
  802 + </div>
  803 + <div className="flex items-center gap-3">
  804 + <Select value={String(catPageSize)} onValueChange={(v) => setCatPageSize(Number(v))}>
  805 + <SelectTrigger className="w-[110px] h-9 rounded-md border border-gray-300 bg-white text-gray-900">
  806 + <SelectValue />
  807 + </SelectTrigger>
  808 + <SelectContent>
  809 + {[10, 20, 50].map((n) => (
  810 + <SelectItem key={n} value={String(n)}>
  811 + {n} / page
  812 + </SelectItem>
  813 + ))}
  814 + </SelectContent>
  815 + </Select>
  816 + <Pagination className="mx-0 w-auto justify-end">
  817 + <PaginationContent>
  818 + <PaginationItem>
  819 + <PaginationPrevious
  820 + href="#"
  821 + size="default"
  822 + onClick={(e) => {
  823 + e.preventDefault();
  824 + setCatPageIndex((p) => Math.max(1, p - 1));
  825 + }}
  826 + aria-disabled={catPageIndex <= 1}
  827 + className={catPageIndex <= 1 ? "pointer-events-none opacity-50" : ""}
  828 + />
  829 + </PaginationItem>
  830 + <PaginationItem>
  831 + <PaginationLink href="#" isActive size="default" onClick={(e) => e.preventDefault()}>
  832 + Page {catPageIndex} / {catTotalPages}
  833 + </PaginationLink>
  834 + </PaginationItem>
  835 + <PaginationItem>
  836 + <PaginationNext
  837 + href="#"
  838 + size="default"
  839 + onClick={(e) => {
  840 + e.preventDefault();
  841 + setCatPageIndex((p) => Math.min(catTotalPages, p + 1));
  842 + }}
  843 + aria-disabled={catPageIndex >= catTotalPages}
  844 + className={catPageIndex >= catTotalPages ? "pointer-events-none opacity-50" : ""}
  845 + />
  846 + </PaginationItem>
  847 + </PaginationContent>
  848 + </Pagination>
  849 + </div>
  850 + </div>
  851 + </div>
587 )} 852 )}
588 </div> 853 </div>
589 854
@@ -613,44 +878,28 @@ export function ProductsView() { @@ -613,44 +878,28 @@ export function ProductsView() {
613 onDeleted={refresh} 878 onDeleted={refresh}
614 /> 879 />
615 880
616 - <CreateCategoryPlaceholderDialog open={isCategoryDialogOpen} onOpenChange={setIsCategoryDialogOpen} />  
617 - </div>  
618 - );  
619 -} 881 + <ProductCategoryFormDialog
  882 + open={isProductCategoryDialogOpen}
  883 + category={editingProductCategory}
  884 + onOpenChange={(o) => {
  885 + setIsProductCategoryDialogOpen(o);
  886 + if (!o) setEditingProductCategory(null);
  887 + }}
  888 + onSaved={() => {
  889 + refreshCategories();
  890 + setIsProductCategoryDialogOpen(false);
  891 + setEditingProductCategory(null);
  892 + }}
  893 + />
620 894
621 -function CategoriesPlaceholderTab({ categories }: { categories: LabelCategoryDto[] }) {  
622 - return (  
623 - <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden">  
624 - <Table>  
625 - <TableHeader>  
626 - <TableRow className="bg-gray-100 hover:bg-gray-100">  
627 - <TableHead className="text-gray-900 font-bold border-r">Category Name</TableHead>  
628 - <TableHead className="text-gray-900 font-bold border-r">Code</TableHead>  
629 - <TableHead className="text-gray-900 font-bold border-r">Status</TableHead>  
630 - </TableRow>  
631 - </TableHeader>  
632 - <TableBody>  
633 - {categories.length === 0 ? (  
634 - <TableRow>  
635 - <TableCell colSpan={3} className="text-center text-gray-500 py-8">  
636 - No categories loaded. Use Labeling → Label Categories to manage.  
637 - </TableCell>  
638 - </TableRow>  
639 - ) : (  
640 - categories.map((c) => (  
641 - <TableRow key={c.id}>  
642 - <TableCell className="border-r font-medium">{toDisplay(c.categoryName)}</TableCell>  
643 - <TableCell className="border-r text-gray-600">{toDisplay(c.categoryCode)}</TableCell>  
644 - <TableCell className="border-r">  
645 - <Badge variant={c.state !== false ? "default" : "secondary"}>  
646 - {c.state !== false ? "active" : "inactive"}  
647 - </Badge>  
648 - </TableCell>  
649 - </TableRow>  
650 - ))  
651 - )}  
652 - </TableBody>  
653 - </Table> 895 + <DeleteProductCategoryDialog
  896 + open={!!deletingProductCategory}
  897 + category={deletingProductCategory}
  898 + onOpenChange={(o) => {
  899 + if (!o) setDeletingProductCategory(null);
  900 + }}
  901 + onDeleted={refreshCategories}
  902 + />
654 </div> 903 </div>
655 ); 904 );
656 } 905 }
@@ -782,12 +1031,13 @@ function ProductFormDialog({ @@ -782,12 +1031,13 @@ function ProductFormDialog({
782 /> 1031 />
783 </div> 1032 </div>
784 <div className="space-y-2"> 1033 <div className="space-y-2">
785 - <Label>Image URL</Label>  
786 - <Input  
787 - className="h-10" 1034 + <Label>Product image</Label>
  1035 + <ImageUrlUpload
788 value={productImageUrl} 1036 value={productImageUrl}
789 - onChange={(e) => setProductImageUrl(e.target.value)}  
790 - placeholder="https://..." 1037 + onChange={setProductImageUrl}
  1038 + uploadSubDir="product"
  1039 + oneImageOnly
  1040 + hint="POST /api/app/picture/category/upload (subDir: product). JPG/PNG/WebP/GIF, max 5 MB."
791 /> 1041 />
792 </div> 1042 </div>
793 <div className="space-y-2"> 1043 <div className="space-y-2">
@@ -862,7 +1112,8 @@ function DeleteProductDialog({ @@ -862,7 +1112,8 @@ function DeleteProductDialog({
862 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> 1112 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
863 Cancel 1113 Cancel
864 </Button> 1114 </Button>
865 - <Button type="button" variant="destructive" disabled={submitting} onClick={submit}> 1115 + <Button type="button" variant="destructive" className="gap-2" disabled={submitting} onClick={submit}>
  1116 + <Trash2 className="h-4 w-4 shrink-0" />
866 {submitting ? "Deleting…" : "Delete"} 1117 {submitting ? "Deleting…" : "Delete"}
867 </Button> 1118 </Button>
868 </DialogFooter> 1119 </DialogFooter>
@@ -871,25 +1122,203 @@ function DeleteProductDialog({ @@ -871,25 +1122,203 @@ function DeleteProductDialog({
871 ); 1122 );
872 } 1123 }
873 1124
874 -function CreateCategoryPlaceholderDialog({ 1125 +function ProductCategoryFormDialog({
875 open, 1126 open,
  1127 + category,
876 onOpenChange, 1128 onOpenChange,
  1129 + onSaved,
877 }: { 1130 }: {
878 open: boolean; 1131 open: boolean;
  1132 + category: ProductCategoryDto | null;
879 onOpenChange: (o: boolean) => void; 1133 onOpenChange: (o: boolean) => void;
  1134 + onSaved: () => void;
880 }) { 1135 }) {
  1136 + const isEdit = !!category?.id;
  1137 + const [submitting, setSubmitting] = useState(false);
  1138 + const [categoryCode, setCategoryCode] = useState("");
  1139 + const [categoryName, setCategoryName] = useState("");
  1140 + const [categoryPhotoUrl, setCategoryPhotoUrl] = useState("");
  1141 + const [orderNum, setOrderNum] = useState("0");
  1142 + const [state, setState] = useState(true);
  1143 +
  1144 + useEffect(() => {
  1145 + if (!open) return;
  1146 + if (category) {
  1147 + setCategoryCode(category.categoryCode ?? "");
  1148 + setCategoryName(category.categoryName ?? "");
  1149 + setCategoryPhotoUrl(category.categoryPhotoUrl ?? "");
  1150 + setOrderNum(
  1151 + category.orderNum === null || category.orderNum === undefined ? "0" : String(category.orderNum),
  1152 + );
  1153 + setState(category.state !== false);
  1154 + } else {
  1155 + setCategoryCode("");
  1156 + setCategoryName("");
  1157 + setCategoryPhotoUrl("");
  1158 + setOrderNum("0");
  1159 + setState(true);
  1160 + }
  1161 + }, [open, category]);
  1162 +
  1163 + const submit = async () => {
  1164 + if (!categoryCode.trim() || !categoryName.trim()) {
  1165 + toast.error("Validation", { description: "Category code and name are required." });
  1166 + return;
  1167 + }
  1168 + const orderParsed = Number(orderNum);
  1169 + if (!Number.isFinite(orderParsed)) {
  1170 + toast.error("Validation", { description: "Order must be a number." });
  1171 + return;
  1172 + }
  1173 +
  1174 + const body: ProductCategoryCreateInput = {
  1175 + categoryCode: categoryCode.trim(),
  1176 + categoryName: categoryName.trim(),
  1177 + categoryPhotoUrl: categoryPhotoUrl.trim() || null,
  1178 + state,
  1179 + orderNum: orderParsed,
  1180 + };
  1181 +
  1182 + setSubmitting(true);
  1183 + try {
  1184 + if (isEdit && category?.id) {
  1185 + await updateProductCategory(category.id, body);
  1186 + toast.success("Category updated.");
  1187 + } else {
  1188 + await createProductCategory(body);
  1189 + toast.success("Category created.");
  1190 + }
  1191 + onSaved();
  1192 + } catch (e: any) {
  1193 + toast.error(isEdit ? "Update failed" : "Create failed", {
  1194 + description: e?.message ? String(e.message) : "",
  1195 + });
  1196 + } finally {
  1197 + setSubmitting(false);
  1198 + }
  1199 + };
  1200 +
881 return ( 1201 return (
882 <Dialog open={open} onOpenChange={onOpenChange}> 1202 <Dialog open={open} onOpenChange={onOpenChange}>
883 - <DialogContent className="sm:max-w-md"> 1203 + <DialogContent className="w-[min(50%,calc(100vw-2rem))] max-w-none sm:max-w-none max-h-[90vh] overflow-y-auto">
884 <DialogHeader> 1204 <DialogHeader>
885 - <DialogTitle>Categories</DialogTitle> 1205 + <DialogTitle>{isEdit ? "Edit Category" : "New Category"}</DialogTitle>
886 <DialogDescription> 1206 <DialogDescription>
887 - Manage categories under <span className="font-medium">Labeling → Label Categories</span> (label module API). 1207 + {isEdit ? "Update product category (API: /api/app/product-category)." : "Create a product category."}
888 </DialogDescription> 1208 </DialogDescription>
889 </DialogHeader> 1209 </DialogHeader>
  1210 +
  1211 + <div className="grid gap-4 py-2">
  1212 + <div className="grid grid-cols-2 gap-4">
  1213 + <div className="space-y-2">
  1214 + <Label>Category code *</Label>
  1215 + <Input
  1216 + className="h-10"
  1217 + value={categoryCode}
  1218 + onChange={(e) => setCategoryCode(e.target.value)}
  1219 + placeholder="e.g. CAT_PREP"
  1220 + />
  1221 + </div>
  1222 + <div className="space-y-2">
  1223 + <Label>Category name *</Label>
  1224 + <Input
  1225 + className="h-10"
  1226 + value={categoryName}
  1227 + onChange={(e) => setCategoryName(e.target.value)}
  1228 + placeholder="e.g. Prep"
  1229 + />
  1230 + </div>
  1231 + </div>
  1232 + <div className="space-y-2">
  1233 + <Label>Category photo</Label>
  1234 + <ImageUrlUpload
  1235 + value={categoryPhotoUrl}
  1236 + onChange={setCategoryPhotoUrl}
  1237 + uploadSubDir="category"
  1238 + oneImageOnly
  1239 + hint="POST /api/app/picture/category/upload. Max 5 MB."
  1240 + />
  1241 + </div>
  1242 + <div className="space-y-2">
  1243 + <Label>Order *</Label>
  1244 + <Input
  1245 + className="h-10"
  1246 + type="number"
  1247 + value={orderNum}
  1248 + onChange={(e) => setOrderNum(e.target.value)}
  1249 + placeholder="0"
  1250 + />
  1251 + </div>
  1252 + <div className="flex items-center justify-between border border-gray-200 rounded-md px-3 h-10 bg-white">
  1253 + <span className="text-sm font-medium">Enabled</span>
  1254 + <Switch checked={state} onCheckedChange={setState} />
  1255 + </div>
  1256 + </div>
  1257 +
  1258 + <DialogFooter>
  1259 + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
  1260 + Cancel
  1261 + </Button>
  1262 + <Button
  1263 + type="button"
  1264 + disabled={submitting}
  1265 + onClick={submit}
  1266 + className="bg-blue-600 hover:bg-blue-700 text-white"
  1267 + >
  1268 + {submitting ? "Saving…" : isEdit ? "Save" : "Create"}
  1269 + </Button>
  1270 + </DialogFooter>
  1271 + </DialogContent>
  1272 + </Dialog>
  1273 + );
  1274 +}
  1275 +
  1276 +function DeleteProductCategoryDialog({
  1277 + open,
  1278 + category,
  1279 + onOpenChange,
  1280 + onDeleted,
  1281 +}: {
  1282 + open: boolean;
  1283 + category: ProductCategoryDto | null;
  1284 + onOpenChange: (o: boolean) => void;
  1285 + onDeleted: () => void;
  1286 +}) {
  1287 + const [submitting, setSubmitting] = useState(false);
  1288 +
  1289 + const submit = async () => {
  1290 + if (!category?.id) return;
  1291 + setSubmitting(true);
  1292 + try {
  1293 + await deleteProductCategory(category.id);
  1294 + toast.success("Category deleted.");
  1295 + onOpenChange(false);
  1296 + onDeleted();
  1297 + } catch (e: any) {
  1298 + toast.error("Delete failed", { description: e?.message ? String(e.message) : "" });
  1299 + } finally {
  1300 + setSubmitting(false);
  1301 + }
  1302 + };
  1303 +
  1304 + return (
  1305 + <Dialog open={open} onOpenChange={onOpenChange}>
  1306 + <DialogContent className="sm:max-w-md">
  1307 + <DialogHeader>
  1308 + <DialogTitle>Delete category</DialogTitle>
  1309 + <DialogDescription>This cannot be undone.</DialogDescription>
  1310 + </DialogHeader>
  1311 + <p className="text-sm text-gray-700 py-2">
  1312 + Delete <span className="font-medium">{toDisplay(category?.categoryName)}</span> (
  1313 + {toDisplay(category?.categoryCode)})?
  1314 + </p>
890 <DialogFooter> 1315 <DialogFooter>
891 - <Button type="button" onClick={() => onOpenChange(false)}>  
892 - OK 1316 + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
  1317 + Cancel
  1318 + </Button>
  1319 + <Button type="button" variant="destructive" className="gap-2" disabled={submitting} onClick={submit}>
  1320 + <Trash2 className="h-4 w-4 shrink-0" />
  1321 + {submitting ? "Deleting…" : "Delete"}
893 </Button> 1322 </Button>
894 </DialogFooter> 1323 </DialogFooter>
895 </DialogContent> 1324 </DialogContent>
美国版/Food Labeling Management Platform/src/components/system-menu/SystemMenuView.tsx
@@ -614,7 +614,8 @@ function DeleteSystemMenuDialog({ @@ -614,7 +614,8 @@ function DeleteSystemMenuDialog({
614 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}> 614 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
615 Cancel 615 Cancel
616 </Button> 616 </Button>
617 - <Button className="min-w-24" variant="destructive" disabled={submitting} onClick={submit}> 617 + <Button className="min-w-24 gap-2" variant="destructive" disabled={submitting} onClick={submit}>
  618 + <Trash2 className="h-4 w-4 shrink-0" />
618 {submitting ? "Deleting..." : "Delete"} 619 {submitting ? "Deleting..." : "Delete"}
619 </Button> 620 </Button>
620 </DialogFooter> 621 </DialogFooter>
美国版/Food Labeling Management Platform/src/components/ui/image-url-upload.tsx 0 → 100644
  1 +import React, { useRef, useState } from "react";
  2 +import { Plus, X } from "lucide-react";
  3 +import { toast } from "sonner";
  4 +import { cn } from "./utils";
  5 +import { PICTURE_UPLOAD_MAX_BYTES, resolvePictureUrlForDisplay, uploadImageFile } from "../../services/imageUploadService";
  6 +
  7 +export type ImageUrlUploadProps = {
  8 + value: string;
  9 + onChange: (url: string) => void;
  10 + disabled?: boolean;
  11 + /** 辅助说明,显示在方框下方 */
  12 + hint?: string;
  13 + /** 空状态主文案 */
  14 + emptyLabel?: string;
  15 + accept?: string;
  16 + /** 默认 5MB,与平台 picture 上传接口一致 */
  17 + maxSizeMb?: number;
  18 + className?: string;
  19 + /** 上传区域宽度(tailwind),默认 max-w-[200px] */
  20 + boxClassName?: string;
  21 + /** 传给后端的 multipart `subDir`(如 category、product) */
  22 + uploadSubDir?: string;
  23 + /** 明确单图:隐藏多选、提示文案 */
  24 + oneImageOnly?: boolean;
  25 +};
  26 +
  27 +export function ImageUrlUpload({
  28 + value,
  29 + onChange,
  30 + disabled,
  31 + hint,
  32 + emptyLabel = "Click to upload image",
  33 + accept = "image/jpeg,image/png,image/webp,image/gif",
  34 + maxSizeMb = PICTURE_UPLOAD_MAX_BYTES / (1024 * 1024),
  35 + className,
  36 + boxClassName,
  37 + uploadSubDir,
  38 + oneImageOnly,
  39 +}: ImageUrlUploadProps) {
  40 + const inputRef = useRef<HTMLInputElement>(null);
  41 + const [uploading, setUploading] = useState(false);
  42 +
  43 + const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
  44 + const file = e.target.files?.[0];
  45 + e.target.value = "";
  46 + if (!file) return;
  47 + if (!file.type.startsWith("image/")) {
  48 + toast.error("Please select an image file.");
  49 + return;
  50 + }
  51 + if (file.size > maxSizeMb * 1024 * 1024) {
  52 + toast.error(`Image must be ${maxSizeMb} MB or smaller.`);
  53 + return;
  54 + }
  55 +
  56 + setUploading(true);
  57 + try {
  58 + const url = await uploadImageFile(file, { subDir: uploadSubDir });
  59 + onChange(url);
  60 + toast.success("Image uploaded.");
  61 + } catch (err: unknown) {
  62 + const msg = err instanceof Error ? err.message : String(err);
  63 + toast.error("Upload failed", { description: msg || undefined });
  64 + } finally {
  65 + setUploading(false);
  66 + }
  67 + };
  68 +
  69 + const busy = disabled || uploading;
  70 +
  71 + const openPicker = () => {
  72 + if (!busy) inputRef.current?.click();
  73 + };
  74 +
  75 + const boxBase =
  76 + "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2";
  77 +
  78 + return (
  79 + <div className={cn("space-y-2", className)}>
  80 + <input
  81 + ref={inputRef}
  82 + type="file"
  83 + accept={accept}
  84 + className="sr-only"
  85 + disabled={busy}
  86 + multiple={false}
  87 + onChange={onFileChange}
  88 + />
  89 +
  90 + {!value ? (
  91 + <button
  92 + type="button"
  93 + disabled={busy}
  94 + onClick={openPicker}
  95 + className={cn(
  96 + boxBase,
  97 + "flex flex-col items-center justify-center gap-3 border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400",
  98 + "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500",
  99 + "disabled:pointer-events-none disabled:opacity-50",
  100 + boxClassName,
  101 + )}
  102 + >
  103 + <Plus className="h-10 w-10 shrink-0 stroke-[1.25]" aria-hidden />
  104 + <span className="px-3 text-center text-sm font-normal leading-tight">
  105 + {uploading ? "Uploading…" : emptyLabel}
  106 + </span>
  107 + </button>
  108 + ) : (
  109 + <div
  110 + className={cn(
  111 + "group relative overflow-hidden rounded-md border-2 border-dashed border-gray-300 bg-gray-50/80",
  112 + boxBase,
  113 + boxClassName,
  114 + )}
  115 + >
  116 + <button
  117 + type="button"
  118 + disabled={busy}
  119 + onClick={openPicker}
  120 + className="relative h-full w-full p-0"
  121 + aria-label="Replace image"
  122 + >
  123 + <img
  124 + src={resolvePictureUrlForDisplay(value)}
  125 + alt=""
  126 + className="h-full w-full object-contain"
  127 + onError={(ev) => {
  128 + (ev.target as HTMLImageElement).style.opacity = "0.2";
  129 + }}
  130 + />
  131 + <span className="absolute inset-0 flex items-center justify-center bg-black/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-black/45 group-hover:opacity-100">
  132 + Click to replace
  133 + </span>
  134 + </button>
  135 + <button
  136 + type="button"
  137 + disabled={busy}
  138 + onClick={(e) => {
  139 + e.stopPropagation();
  140 + onChange("");
  141 + }}
  142 + className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-white/95 text-gray-600 shadow-sm ring-1 ring-gray-200 transition hover:bg-white hover:text-gray-900 disabled:opacity-50"
  143 + aria-label="Remove image"
  144 + >
  145 + <X className="h-4 w-4" />
  146 + </button>
  147 + </div>
  148 + )}
  149 +
  150 + {oneImageOnly ? (
  151 + <p className="text-xs text-muted-foreground">One image only. Replace or clear to change.</p>
  152 + ) : null}
  153 + {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
  154 + </div>
  155 + );
  156 +}
美国版/Food Labeling Management Platform/src/services/imageUploadService.ts 0 → 100644
  1 +/**
  2 + * 平台端图片上传:对接 `/api/app/picture/category/upload`(见《平台端Categories图片上传接口说明》)。
  3 + * 返回的 `url` 为相对路径(如 `/picture/category/xxx.png`),保存到业务字段;展示时用 `resolvePictureUrlForDisplay`。
  4 + */
  5 +
  6 +/** 与文档一致:POST multipart,字段 file + 可选 subDir */
  7 +export const PICTURE_CATEGORY_UPLOAD_PATH = "/api/app/picture/category/upload";
  8 +
  9 +function joinBaseAndPath(baseUrl: string, path: string): string {
  10 + const b = baseUrl.replace(/\/$/, "");
  11 + const p = path.startsWith("/") ? path : `/${path}`;
  12 + return `${b}${p}`;
  13 +}
  14 +
  15 +/** 与后端限制一致:5MB */
  16 +export const PICTURE_UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
  17 +
  18 +export type UploadImageOptions = {
  19 + /** 可选子目录,如 `category`、`product`;禁止包含 `..` */
  20 + subDir?: string;
  21 + signal?: AbortSignal;
  22 +};
  23 +
  24 +function getApiBaseUrl(): string {
  25 + return (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "http://localhost:19001";
  26 +}
  27 +
  28 +function getToken(): string | null {
  29 + try {
  30 + return localStorage.getItem("access_token") ?? localStorage.getItem("token");
  31 + } catch {
  32 + return null;
  33 + }
  34 +}
  35 +
  36 +function pickUrlFromPayload(x: unknown): string | null {
  37 + if (typeof x === "string" && x.trim()) {
  38 + const t = x.trim();
  39 + if (/^https?:\/\//i.test(t) || t.startsWith("/") || t.startsWith("data:")) return t;
  40 + }
  41 + if (!x || typeof x !== "object") return null;
  42 + const o = x as Record<string, unknown>;
  43 + for (const k of ["url", "Url", "fileUrl", "FileUrl", "imageUrl", "ImageUrl", "path", "Path"]) {
  44 + const v = o[k];
  45 + if (typeof v === "string" && v.trim()) return v.trim();
  46 + }
  47 + return null;
  48 +}
  49 +
  50 +function unwrapData(payload: unknown): unknown {
  51 + if (!payload || typeof payload !== "object") return payload;
  52 + const o = payload as Record<string, unknown>;
  53 + if ("data" in o && o.data !== undefined) return o.data;
  54 + if ("result" in o && o.result !== undefined) return o.result;
  55 + return payload;
  56 +}
  57 +
  58 +function messageFromErrorPayload(payload: unknown, status: number): string {
  59 + if (payload && typeof payload === "object") {
  60 + const o = payload as Record<string, unknown>;
  61 + const err = o.errors ?? o.Errors;
  62 + if (typeof err === "string" && err.trim()) return err.trim();
  63 + const nested = o.error as { message?: string } | undefined;
  64 + if (nested && typeof nested.message === "string" && nested.message.trim()) return nested.message.trim();
  65 + }
  66 + return `Upload failed (${status})`;
  67 +}
  68 +
  69 +/**
  70 + * 将保存的地址转为浏览器可请求的绝对 URL(相对 `/picture/...` 会拼上 API 宿主)。
  71 + */
  72 +export function resolvePictureUrlForDisplay(stored: string): string {
  73 + const s = (stored ?? "").trim();
  74 + if (!s) return "";
  75 + if (s.startsWith("data:")) return s;
  76 + if (/^https?:\/\//i.test(s)) return s;
  77 + const base = getApiBaseUrl();
  78 + return s.startsWith("/") ? `${base}${s}` : `${base}/${s}`;
  79 +}
  80 +
  81 +function validateFile(file: File): void {
  82 + if (file.size > PICTURE_UPLOAD_MAX_BYTES) {
  83 + throw new Error("Image must be 5 MB or smaller.");
  84 + }
  85 + const okMime = /^(image\/(jpeg|png|webp|gif))$/i.test(file.type);
  86 + const okExt = /\.(jpe?g|png|webp|gif)$/i.test(file.name);
  87 + if (!okMime && !okExt) {
  88 + throw new Error("Only JPG, PNG, WebP, and GIF are allowed.");
  89 + }
  90 +}
  91 +
  92 +function readFileAsDataUrl(file: File): Promise<string> {
  93 + return new Promise((resolve, reject) => {
  94 + const reader = new FileReader();
  95 + reader.onload = () => {
  96 + const r = reader.result;
  97 + if (typeof r === "string") resolve(r);
  98 + else reject(new Error("Failed to read file."));
  99 + };
  100 + reader.onerror = () => reject(new Error("Failed to read file."));
  101 + reader.readAsDataURL(file);
  102 + });
  103 +}
  104 +
  105 +/**
  106 + * 上传图片;返回后端 `PictureUploadOutputDto.url`(相对路径,可写入 CategoryPhotoUrl 等字段)。
  107 + */
  108 +export async function uploadImageFile(file: File, options?: UploadImageOptions): Promise<string> {
  109 + if (import.meta.env.VITE_IMAGE_UPLOAD_MOCK === "true") {
  110 + validateFile(file);
  111 + return readFileAsDataUrl(file);
  112 + }
  113 +
  114 + validateFile(file);
  115 +
  116 + const sub = options?.subDir?.trim();
  117 + if (sub && sub.includes("..")) {
  118 + throw new Error("Invalid subDir.");
  119 + }
  120 +
  121 + const uploadUrl = joinBaseAndPath(getApiBaseUrl(), PICTURE_CATEGORY_UPLOAD_PATH);
  122 +
  123 + const fd = new FormData();
  124 + fd.append("file", file);
  125 + if (sub) fd.append("subDir", sub);
  126 +
  127 + const headers: Record<string, string> = {};
  128 + const token = getToken();
  129 + if (token) headers.Authorization = `Bearer ${token}`;
  130 +
  131 + const res = await fetch(uploadUrl, {
  132 + method: "POST",
  133 + body: fd,
  134 + headers,
  135 + signal: options?.signal,
  136 + });
  137 +
  138 + const text = await res.text();
  139 + let payload: unknown = text;
  140 + try {
  141 + payload = text ? JSON.parse(text) : null;
  142 + } catch {
  143 + payload = text;
  144 + }
  145 +
  146 + if (!res.ok) {
  147 + throw new Error(messageFromErrorPayload(payload, res.status));
  148 + }
  149 +
  150 + const inner = unwrapData(payload);
  151 + const url = pickUrlFromPayload(inner) ?? pickUrlFromPayload(payload);
  152 + if (!url) {
  153 + throw new Error("Upload response did not contain a usable image URL.");
  154 + }
  155 + return url;
  156 +}
美国版/Food Labeling Management Platform/src/services/productCategoryService.ts 0 → 100644
  1 +import { createApiClient } from "../lib/apiClient";
  2 +import type {
  3 + ProductCategoryCreateInput,
  4 + ProductCategoryDto,
  5 + ProductCategoryGetListInput,
  6 + ProductCategoryPagedResult,
  7 + ProductCategoryUpdateInput,
  8 +} from "../types/productCategory";
  9 +
  10 +const api = createApiClient({
  11 + getToken: () => {
  12 + try {
  13 + return localStorage.getItem("access_token") ?? localStorage.getItem("token") ?? null;
  14 + } catch {
  15 + return null;
  16 + }
  17 + },
  18 +});
  19 +
  20 +const PATH = "/product-category";
  21 +
  22 +export async function getProductCategories(
  23 + input: ProductCategoryGetListInput,
  24 + signal?: AbortSignal,
  25 +): Promise<ProductCategoryPagedResult> {
  26 + return api.requestJson<ProductCategoryPagedResult>({
  27 + path: PATH,
  28 + method: "GET",
  29 + query: {
  30 + SkipCount: input.skipCount,
  31 + MaxResultCount: input.maxResultCount,
  32 + Sorting: input.sorting,
  33 + Keyword: input.keyword,
  34 + State: input.state,
  35 + },
  36 + signal,
  37 + });
  38 +}
  39 +
  40 +export async function getProductCategory(id: string, signal?: AbortSignal): Promise<ProductCategoryDto> {
  41 + return api.requestJson<ProductCategoryDto>({
  42 + path: `${PATH}/${encodeURIComponent(id)}`,
  43 + method: "GET",
  44 + signal,
  45 + });
  46 +}
  47 +
  48 +export async function createProductCategory(input: ProductCategoryCreateInput): Promise<ProductCategoryDto> {
  49 + return api.requestJson<ProductCategoryDto>({
  50 + path: PATH,
  51 + method: "POST",
  52 + body: {
  53 + categoryCode: input.categoryCode,
  54 + categoryName: input.categoryName,
  55 + categoryPhotoUrl: input.categoryPhotoUrl ?? null,
  56 + state: input.state ?? true,
  57 + orderNum: input.orderNum ?? 0,
  58 + },
  59 + });
  60 +}
  61 +
  62 +export async function updateProductCategory(
  63 + id: string,
  64 + input: ProductCategoryUpdateInput,
  65 +): Promise<ProductCategoryDto> {
  66 + return api.requestJson<ProductCategoryDto>({
  67 + path: `${PATH}/${encodeURIComponent(id)}`,
  68 + method: "PUT",
  69 + body: {
  70 + categoryCode: input.categoryCode,
  71 + categoryName: input.categoryName,
  72 + categoryPhotoUrl: input.categoryPhotoUrl ?? null,
  73 + state: input.state ?? true,
  74 + orderNum: input.orderNum ?? 0,
  75 + },
  76 + });
  77 +}
  78 +
  79 +export async function deleteProductCategory(id: string): Promise<void> {
  80 + await api.requestJson<unknown>({
  81 + path: `${PATH}/${encodeURIComponent(id)}`,
  82 + method: "DELETE",
  83 + });
  84 +}
美国版/Food Labeling Management Platform/src/types/label.ts
@@ -18,6 +18,8 @@ export type LabelDto = { @@ -18,6 +18,8 @@ export type LabelDto = {
18 labelTypeName?: string | null; 18 labelTypeName?: string | null;
19 /** 列表接口:模板展示名 */ 19 /** 列表接口:模板展示名 */
20 templateName?: string | null; 20 templateName?: string | null;
  21 + /** 列表接口:关联产品名称汇总(逗号分隔等,与后端 `products` 一致) */
  22 + products?: string | null;
21 /** 列表接口:关联产品展示名(按产品展开时可能为单条) */ 23 /** 列表接口:关联产品展示名(按产品展开时可能为单条) */
22 productName?: string | null; 24 productName?: string | null;
23 /** 列表接口:产品类别展示名 */ 25 /** 列表接口:产品类别展示名 */
美国版/Food Labeling Management Platform/src/types/productCategory.ts 0 → 100644
  1 +import type { PagedResultDto } from "./labelCategory";
  2 +
  3 +/** 产品模块 Categories(/api/app/product-category) */
  4 +export type ProductCategoryDto = {
  5 + id: string;
  6 + categoryCode: string;
  7 + categoryName: string;
  8 + categoryPhotoUrl?: string | null;
  9 + state?: boolean;
  10 + orderNum?: number | null;
  11 + lastEdited?: string | null;
  12 +};
  13 +
  14 +export type ProductCategoryGetListInput = {
  15 + skipCount: number;
  16 + maxResultCount: number;
  17 + sorting?: string;
  18 + keyword?: string;
  19 + state?: boolean;
  20 +};
  21 +
  22 +export type ProductCategoryCreateInput = {
  23 + categoryCode: string;
  24 + categoryName: string;
  25 + categoryPhotoUrl?: string | null;
  26 + state?: boolean;
  27 + orderNum?: number | null;
  28 +};
  29 +
  30 +export type ProductCategoryUpdateInput = ProductCategoryCreateInput;
  31 +
  32 +/** 列表接口可能带 pageIndex/pageSize/totalPages(与 PagedResultDto 并存) */
  33 +export type ProductCategoryPagedResult = PagedResultDto<ProductCategoryDto> & {
  34 + pageIndex?: number;
  35 + pageSize?: number;
  36 + totalPages?: number;
  37 +};
美国版App登录接口说明.md deleted
1 -# 美国版 App 登录接口说明  
2 -  
3 -## 概述  
4 -  
5 -美国版移动端认证由 `food-labeling-us` 模块的 **`UsAppAuthAppService`** 提供,采用 ABP 约定式动态 API。宿主统一前缀为 **`/api/app`**,建议以 Swagger 为准核对路径(本地示例:`http://localhost:19001/swagger`,搜索 `UsAppAuth`)。  
6 -  
7 -| 说明 | 内容 |  
8 -|------|------|  
9 -| 账号标识 | 使用 **`User.Email`**(邮箱)登录,邮箱比对**忽略大小写** |  
10 -| 密码 | 与 Web 共用 `User` 表,校验方式与 RBAC **`AccountManager`** 一致(盐值 + `MD5Helper.SHA2Encode`) |  
11 -| 验证码 | 当配置 **`Rbac:EnableCaptcha`** 为 `true` 时,需先拉取图形验证码,本接口入参传 `uuid`、`code`;未开启时可传空或不传 |  
12 -  
13 ----  
14 -  
15 -## 接口 1:App 登录  
16 -  
17 -签发 **Access Token**、**Refresh Token**,并返回当前用户在 **`userlocation`** 中绑定的门店列表(关联 **`location`** 表详情)。  
18 -  
19 -### HTTP  
20 -  
21 -- **方法**:`POST`  
22 -- **路径**:`/api/app/us-app-auth/login`  
23 -- **Content-Type**:`application/json`  
24 -- **鉴权**:无需登录(匿名)  
25 -  
26 -### 请求体参数(UsAppLoginInputVo)  
27 -  
28 -| 参数名(JSON) | 类型 | 必填 | 说明 |  
29 -|----------------|------|------|------|  
30 -| `email` | string | 是 | 登录邮箱,对应数据库 `User.Email` |  
31 -| `password` | string | 是 | 明文密码 |  
32 -| `uuid` | string | 条件 | 图形验证码 UUID;**开启验证码时必填** |  
33 -| `code` | string | 条件 | 图形验证码;**开启验证码时必填** |  
34 -  
35 -### 传参示例(请求 Body)  
36 -  
37 -未开启图形验证码时:  
38 -  
39 -```json  
40 -{  
41 - "email": "admin@example.com",  
42 - "password": "123456"  
43 -}  
44 -```  
45 -  
46 -开启图形验证码时(需与系统验证码接口返回的 `uuid`、用户输入的验证码一致):  
47 -  
48 -```json  
49 -{  
50 - "email": "test@example.com",  
51 - "password": "您的密码",  
52 - "uuid": "验证码接口返回的 uuid",  
53 - "code": "用户看到的验证码"  
54 -}  
55 -```  
56 -  
57 -### 响应体(UsAppLoginOutputDto)  
58 -  
59 -| 字段(JSON) | 类型 | 说明 |  
60 -|--------------|------|------|  
61 -| `token` | string | 访问令牌(Bearer),后续业务接口放在 Header `Authorization: Bearer {token}` |  
62 -| `refreshToken` | string | 刷新令牌(与系统账号体系一致,用于刷新 access token,具体用法与 Web 一致) |  
63 -| `locations` | array | 绑定门店列表,元素见下表 |  
64 -  
65 -#### `locations[]` 元素(UsAppBoundLocationDto)  
66 -  
67 -| 字段(JSON) | 类型 | 说明 |  
68 -|--------------|------|------|  
69 -| `id` | string | 门店主键(Guid 字符串) |  
70 -| `locationCode` | string | 业务编码,如 LOC-1 |  
71 -| `locationName` | string | 门店名称 |  
72 -| `fullAddress` | string | 拼接后的完整地址(街道、城市、州、邮编等;无数据时可能为 `"无"`) |  
73 -| `state` | bool | 门店是否启用 |  
74 -  
75 -### 响应示例  
76 -  
77 -```json  
78 -{  
79 - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",  
80 - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",  
81 - "locations": [  
82 - {  
83 - "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",  
84 - "locationCode": "LOC-1",  
85 - "locationName": "Downtown Kitchen",  
86 - "fullAddress": "123 Main St, New York, NY 10001",  
87 - "state": true  
88 - }  
89 - ]  
90 -}  
91 -```  
92 -  
93 -### 常见错误提示(业务异常文案)  
94 -  
95 -- 邮箱或密码为空:`请输入合理数据!`  
96 -- 邮箱在库中不存在(未删除且启用用户中无匹配邮箱):`登录失败!邮箱不存在!`  
97 -- 密码错误:`登录失败!用户名或密码错误!`(与 `UserConst.Login_Error` 一致)  
98 -- 验证码错误(开启验证码时):`验证码错误`  
99 -  
100 ----  
101 -  
102 -## 接口 2:获取当前账号绑定门店  
103 -  
104 -无需重新登录即可刷新 **`userlocation`** 绑定门店列表(例如切换门店前先同步列表)。  
105 -  
106 -### HTTP  
107 -  
108 -- **方法**:`GET`  
109 -- **路径**:`/api/app/us-app-auth/my-locations`  
110 -- **鉴权**:需要登录,请求头携带 **`Authorization: Bearer {token}`**(使用接口 1 返回的 `token`)  
111 -  
112 -### 请求参数  
113 -  
114 -无 Query / Body 参数;用户身份由 JWT 解析。  
115 -  
116 -### 传参示例  
117 -  
118 -```http  
119 -GET /api/app/us-app-auth/my-locations HTTP/1.1  
120 -Host: localhost:19001  
121 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...  
122 -```  
123 -  
124 -若前端统一约定 GET 使用 `data` 封装,可自行在客户端组装;本接口服务端**不读取额外 Query 参数**。  
125 -  
126 -### 响应体  
127 -  
128 -与登录接口中 **`locations`** 相同:**`UsAppBoundLocationDto[]`**(数组)。  
129 -  
130 -### 响应示例  
131 -  
132 -```json  
133 -[  
134 - {  
135 - "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",  
136 - "locationCode": "LOC-1",  
137 - "locationName": "Downtown Kitchen",  
138 - "fullAddress": "123 Main St, New York, NY 10001",  
139 - "state": true  
140 - }  
141 -]  
142 -```  
143 -  
144 -### 常见错误  
145 -  
146 -- 未登录或 Token 无效:按网关/ABP 返回 401 及统一错误体  
147 -- 无用户上下文:`用户未登录`  
148 -  
149 ----  
150 -  
151 -## 与其他登录方式的区别  
152 -  
153 -| 场景 | 说明 |  
154 -|------|------|  
155 -| Web 管理端 | 仍使用 RBAC **`AccountService.PostLoginAsync`**,一般为人 **`userName`** + 密码 |  
156 -| 美国版 App | **仅**本模块 **`/api/app/us-app-auth/login`** 使用 **邮箱 + 密码** |  
157 -  
158 -两者共用同一 `User` 表与 JWT 体系;App 端需保证账号已维护 **`Email`** 字段,否则无法通过邮箱登录。