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 1 <template>
2 2 <view class="loc-root">
3 3 <view class="loc-trigger" @click.stop="openPicker">
4   - <text class="loc-text">{{ displayCode || '—' }}</text>
  4 + <text class="loc-text">{{ displayLocationName || '—' }}</text>
5 5 <AppIcon name="chevronDown" size="sm" color="white" />
6 6 </view>
7 7  
... ... @@ -48,7 +48,7 @@ import {
48 48 getBoundLocations,
49 49 getCurrentLocationCode,
50 50 } from '../utils/stores'
51   -import type { UsAppBoundLocationDto } from '../services/usAppAuth'
  51 +import type { UsAppBoundLocationDto } from '../types/usAppBound'
52 52  
53 53 const { t } = useI18n()
54 54 const stores = ref<UsAppBoundLocationDto[]>([])
... ... @@ -59,12 +59,21 @@ function refreshList() {
59 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 66 if (currentId.value) {
64 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 79 function openPicker() {
... ... @@ -111,6 +120,10 @@ const handleSelect = (s: UsAppBoundLocationDto) =&gt; {
111 120 font-size: 22rpx;
112 121 color: rgba(255, 255, 255, 0.9);
113 122 font-weight: 500;
  123 + max-width: 320rpx;
  124 + overflow: hidden;
  125 + text-overflow: ellipsis;
  126 + white-space: nowrap;
114 127 }
115 128  
116 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 51 const { t } = useI18n()
52 52 const statusBarHeight = getStatusBarHeight()
53 53  
54   -const storeName = computed(() => uni.getStorageSync('storeName') || 'MedVantage')
  54 +const storeName = computed(() => uni.getStorageSync('storeName') || 'None Selected')
55 55 const isMenuOpen = ref(false)
56 56  
57 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 68 import { onShow } from '@dcloudio/uni-app'
69 69 import AppIcon from '../../components/AppIcon.vue'
70 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 74 import { setBoundLocations, getBoundLocations } from '../../utils/authSession'
73 75 import { switchStore } from '../../utils/stores'
74 76  
... ... @@ -91,7 +93,8 @@ async function refreshFromApi() {
91 93 try {
92 94 const list = await usAppFetchMyLocations()
93 95 applyList(list)
94   - } catch {
  96 + } catch (e) {
  97 + if (isUsAppSessionExpiredError(e)) return
95 98 applyList(getBoundLocations())
96 99 uni.showToast({ title: t('login.refreshStoresFail'), icon: 'none' })
97 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 6 export interface UsAppLoginInput {
13 7 email: string
... ... @@ -48,102 +42,12 @@ function normalizeLocationList(raw: unknown): UsAppBoundLocationDto[] {
48 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 46 export async function usAppLogin(input: UsAppLoginInput): Promise<UsAppLoginOutputDto> {
144   - const raw = await request<unknown>({
  47 + const raw = await usAppApiRequest<unknown>({
145 48 path: '/api/app/us-app-auth/login',
146 49 method: 'POST',
  50 + skipUnauthorizedRedirect: true,
147 51 data: {
148 52 email: input.email.trim(),
149 53 password: input.password,
... ... @@ -156,7 +60,7 @@ export async function usAppLogin(input: UsAppLoginInput): Promise&lt;UsAppLoginOutp
156 60  
157 61 /** GET /api/app/us-app-auth/my-locations */
158 62 export async function usAppFetchMyLocations(): Promise<UsAppBoundLocationDto[]> {
159   - const raw = await request<unknown>({
  63 + const raw = await usAppApiRequest<unknown>({
160 64 path: '/api/app/us-app-auth/my-locations',
161 65 method: 'GET',
162 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 3 const KEY_ACCESS = 'access_token'
4 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 2 import { getBoundLocations } from './authSession'
3 3  
4 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 5 <meta charset="UTF-8" />
6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 7 <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-1_5dt1NK.js"></script>
  8 + <script type="module" crossorigin src="/assets/index-C_mEdGxy.js"></script>
9 9 <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css">
10 10 </head>
11 11  
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
... ... @@ -25,9 +25,10 @@ import {
25 25 DialogTitle,
26 26 } from "../ui/dialog";
27 27 import { Label } from "../ui/label";
  28 +import { ImageUrlUpload } from "../ui/image-url-upload";
28 29 import { Switch } from "../ui/switch";
29 30 import { Badge } from "../ui/badge";
30   -import { Plus, Edit, MoreHorizontal } from "lucide-react";
  31 +import { Plus, Edit, MoreHorizontal, Trash2 } from "lucide-react";
31 32 import { toast } from "sonner";
32 33 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 34 import {
... ... @@ -45,6 +46,7 @@ import {
45 46 updateLabelCategory,
46 47 deleteLabelCategory,
47 48 } from "../../services/labelCategoryService";
  49 +import { resolvePictureUrlForDisplay } from "../../services/imageUploadService";
48 50 import type {
49 51 LabelCategoryDto,
50 52 LabelCategoryCreateInput,
... ... @@ -209,7 +211,17 @@ export function LabelCategoriesView() {
209 211 <TableRow key={item.id} className="hover:bg-gray-50">
210 212 <TableCell className="font-medium">{toDisplay(item.categoryName)}</TableCell>
211 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 225 <TableCell>
214 226 <Badge className={item.state ? "bg-green-600" : "bg-gray-400"}>
215 227 {item.state ? "Active" : "Inactive"}
... ... @@ -248,9 +260,10 @@ export function LabelCategoriesView() {
248 260 <Button
249 261 type="button"
250 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 264 onClick={() => openDelete(item)}
253 265 >
  266 + <Trash2 className="w-4 h-4 shrink-0" />
254 267 Delete
255 268 </Button>
256 269 </PopoverContent>
... ... @@ -451,11 +464,13 @@ function CreateLabelCategoryDialog({
451 464 </div>
452 465  
453 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 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 475 </div>
461 476  
... ... @@ -583,11 +598,13 @@ function EditLabelCategoryDialog({
583 598 </div>
584 599  
585 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 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 609 </div>
593 610  
... ... @@ -675,11 +692,12 @@ function DeleteLabelCategoryDialog({
675 692 Cancel
676 693 </Button>
677 694 <Button
678   - className="min-w-24"
  695 + className="min-w-24 gap-2"
679 696 variant="destructive"
680 697 disabled={submitting}
681 698 onClick={submit}
682 699 >
  700 + <Trash2 className="h-4 w-4 shrink-0" />
683 701 {submitting ? "Deleting..." : "Delete"}
684 702 </Button>
685 703 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
... ... @@ -4,6 +4,7 @@ import { QRCodeSVG } from &#39;qrcode.react&#39;;
4 4 import type { LabelTemplate, LabelElement, ElementType } from '../../../types/labelTemplate';
5 5 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate';
6 6 import { cn } from '../../ui/utils';
  7 +import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService';
7 8 import {
8 9 Select,
9 10 SelectContent,
... ... @@ -219,7 +220,7 @@ function ElementContent({ el }: { el: LabelElement }) {
219 220 if (src) {
220 221 return (
221 222 <img
222   - src={src}
  223 + src={resolvePictureUrlForDisplay(src)}
223 224 alt=""
224 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 24 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption';
25 25 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
26 26 import { Checkbox } from '../../ui/checkbox';
  27 +import { ImageUrlUpload } from '../../ui/image-url-upload';
  28 +import { Trash2 } from 'lucide-react';
27 29  
28 30 interface PropertiesPanelProps {
29 31 template: LabelTemplate;
... ... @@ -155,9 +157,10 @@ export function PropertiesPanel({
155 157 <div className="pt-4 border-t border-gray-100">
156 158 <Button
157 159 variant="destructive"
158   - className="w-full"
  160 + className="w-full gap-2"
159 161 onClick={() => onDeleteElement(selectedElement.id)}
160 162 >
  163 + <Trash2 className="h-4 w-4 shrink-0" />
161 164 Delete Element
162 165 </Button>
163 166 </div>
... ... @@ -556,13 +559,17 @@ function ElementConfigFields({
556 559 return (
557 560 <>
558 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 573 </div>
567 574 <div>
568 575 <Label className="text-xs">Scale Mode</Label>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
... ... @@ -24,7 +24,7 @@ import {
24 24 DialogHeader,
25 25 DialogTitle,
26 26 } from '../ui/dialog';
27   -import { Plus, Pencil, MoreHorizontal } from 'lucide-react';
  27 +import { Plus, Pencil, MoreHorizontal, Trash2 } from 'lucide-react';
28 28 import { toast } from 'sonner';
29 29 import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
30 30 import {
... ... @@ -385,9 +385,10 @@ export function LabelTemplatesView() {
385 385 <Button
386 386 type="button"
387 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 389 onClick={() => openDelete(t)}
390 390 >
  391 + <Trash2 className="w-4 h-4 shrink-0" />
391 392 Delete
392 393 </Button>
393 394 </PopoverContent>
... ... @@ -529,11 +530,12 @@ function DeleteLabelTemplateDialog({
529 530 Cancel
530 531 </Button>
531 532 <Button
532   - className="min-w-24"
  533 + className="min-w-24 gap-2"
533 534 variant="destructive"
534 535 disabled={submitting}
535 536 onClick={submit}
536 537 >
  538 + <Trash2 className="h-4 w-4 shrink-0" />
537 539 {submitting ? "Deleting..." : "Delete"}
538 540 </Button>
539 541 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTypesView.tsx
... ... @@ -27,7 +27,7 @@ import {
27 27 import { Label } from "../ui/label";
28 28 import { Switch } from "../ui/switch";
29 29 import { Badge } from "../ui/badge";
30   -import { Plus, Edit, MoreHorizontal } from "lucide-react";
  30 +import { Plus, Edit, MoreHorizontal, Trash2 } from "lucide-react";
31 31 import { toast } from "sonner";
32 32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 33 import {
... ... @@ -245,9 +245,10 @@ export function LabelTypesView() {
245 245 <Button
246 246 type="button"
247 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 249 onClick={() => openDelete(item)}
250 250 >
  251 + <Trash2 className="w-4 h-4 shrink-0" />
251 252 Delete
252 253 </Button>
253 254 </PopoverContent>
... ... @@ -649,11 +650,12 @@ function DeleteLabelTypeDialog({
649 650 Cancel
650 651 </Button>
651 652 <Button
652   - className="min-w-24"
  653 + className="min-w-24 gap-2"
653 654 variant="destructive"
654 655 disabled={submitting}
655 656 onClick={submit}
656 657 >
  658 + <Trash2 className="h-4 w-4 shrink-0" />
657 659 {submitting ? "Deleting..." : "Delete"}
658 660 </Button>
659 661 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
... ... @@ -27,7 +27,7 @@ import {
27 27 import { Label } from "../ui/label";
28 28 import { Switch } from "../ui/switch";
29 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 31 import { toast } from "sonner";
32 32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 33 import { Checkbox } from "../ui/checkbox";
... ... @@ -72,8 +72,10 @@ function labelRowCode(item: LabelDto): string {
72 72 return c || "None";
73 73 }
74 74  
75   -/** 列表行:产品列(优先展示名称,否则展示绑定数量) */
  75 +/** 列表行:产品列(接口可能返回 `products` 汇总字符串或 `productName` / productIds) */
76 76 function labelRowProductsText(item: LabelDto): string {
  77 + const aggregated = (item.products ?? "").trim();
  78 + if (aggregated) return aggregated;
77 79 const pn = (item.productName ?? "").trim();
78 80 if (pn) return pn;
79 81 const n = item.productIds?.length ?? 0;
... ... @@ -523,9 +525,10 @@ export function LabelsList() {
523 525 <Button
524 526 type="button"
525 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 529 onClick={() => openDelete(item)}
528 530 >
  531 + <Trash2 className="w-4 h-4 shrink-0" />
529 532 Delete
530 533 </Button>
531 534 </PopoverContent>
... ... @@ -1165,11 +1168,12 @@ function DeleteLabelDialog({
1165 1168 Cancel
1166 1169 </Button>
1167 1170 <Button
1168   - className="min-w-24"
  1171 + className="min-w-24 gap-2"
1169 1172 variant="destructive"
1170 1173 disabled={submitting}
1171 1174 onClick={submit}
1172 1175 >
  1176 + <Trash2 className="h-4 w-4 shrink-0" />
1173 1177 {submitting ? "Deleting..." : "Delete"}
1174 1178 </Button>
1175 1179 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/labels/MultipleOptionsView.tsx
... ... @@ -27,7 +27,7 @@ import {
27 27 import { Label } from "../ui/label";
28 28 import { Switch } from "../ui/switch";
29 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 31 import { toast } from "sonner";
32 32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 33 import {
... ... @@ -251,9 +251,10 @@ export function MultipleOptionsView() {
251 251 <Button
252 252 type="button"
253 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 255 onClick={() => openDelete(item)}
256 256 >
  257 + <Trash2 className="w-4 h-4 shrink-0" />
257 258 Delete
258 259 </Button>
259 260 </PopoverContent>
... ... @@ -793,11 +794,12 @@ function DeleteMultipleOptionDialog({
793 794 Cancel
794 795 </Button>
795 796 <Button
796   - className="min-w-24"
  797 + className="min-w-24 gap-2"
797 798 variant="destructive"
798 799 disabled={submitting}
799 800 onClick={submit}
800 801 >
  802 + <Trash2 className="h-4 w-4 shrink-0" />
801 803 {submitting ? "Deleting..." : "Delete"}
802 804 </Button>
803 805 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
1 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 3 import { Button } from "../ui/button";
4 4 import { Input } from "../ui/input";
5 5 import {
... ... @@ -373,9 +373,10 @@ export function LocationsView() {
373 373 <Button
374 374 type="button"
375 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 377 onClick={() => openDelete(loc)}
378 378 >
  379 + <Trash2 className="w-4 h-4 shrink-0" />
379 380 Delete
380 381 </Button>
381 382 </PopoverContent>
... ... @@ -1067,11 +1068,12 @@ function DeleteLocationDialog({
1067 1068 <DialogFooter className="flex-row flex-wrap justify-end">
1068 1069 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
1069 1070 <Button
1070   - className="min-w-24"
  1071 + className="min-w-24 gap-2"
1071 1072 variant="destructive"
1072 1073 disabled={submitting}
1073 1074 onClick={submit}
1074 1075 >
  1076 + <Trash2 className="h-4 w-4 shrink-0" />
1075 1077 {submitting ? "Deleting..." : "Delete"}
1076 1078 </Button>
1077 1079 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/menus/MenuManagementView.tsx
... ... @@ -16,6 +16,13 @@ import {
16 16 } from "../ui/dialog";
17 17 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
18 18 import {
  19 + Select,
  20 + SelectContent,
  21 + SelectItem,
  22 + SelectTrigger,
  23 + SelectValue,
  24 +} from "../ui/select";
  25 +import {
19 26 Table,
20 27 TableBody,
21 28 TableCell,
... ... @@ -67,7 +74,7 @@ export function MenuManagementView() {
67 74 const [debouncedKeyword, setDebouncedKeyword] = useState("");
68 75  
69 76 const [pageIndex, setPageIndex] = useState(1);
70   - const [pageSize] = useState(10);
  77 + const [pageSize, setPageSize] = useState(10);
71 78  
72 79 const [isCreateOpen, setIsCreateOpen] = useState(false);
73 80 const [isEditOpen, setIsEditOpen] = useState(false);
... ... @@ -89,7 +96,18 @@ export function MenuManagementView() {
89 96 setPageIndex(1);
90 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 112 useEffect(() => {
95 113 const run = async () => {
... ... @@ -238,50 +256,60 @@ export function MenuManagementView() {
238 256 </Table>
239 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 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 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 313 </div>
286 314 </div>
287 315  
... ... @@ -497,7 +525,8 @@ function DeleteMenuDialog({
497 525 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
498 526 Cancel
499 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 530 {submitting ? "Deleting..." : "Delete"}
502 531 </Button>
503 532 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
... ... @@ -1190,7 +1190,13 @@ function RoleMenuPermissionsDialog({
1190 1190 <Button variant="outline" onClick={() => onOpenChange(false)}>
1191 1191 Cancel
1192 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 1200 Delete Selected
1195 1201 </Button>
1196 1202 <Button disabled={submitting || !roleId} onClick={submit} className="bg-blue-600 text-white hover:bg-blue-700">
... ... @@ -1257,11 +1263,12 @@ function DeleteRoleDialog({
1257 1263 Cancel
1258 1264 </Button>
1259 1265 <Button
1260   - className="min-w-24"
  1266 + className="min-w-24 gap-2"
1261 1267 variant="destructive"
1262 1268 disabled={submitting}
1263 1269 onClick={submit}
1264 1270 >
  1271 + <Trash2 className="h-4 w-4 shrink-0" />
1265 1272 {submitting ? "Deleting..." : "Delete"}
1266 1273 </Button>
1267 1274 </DialogFooter>
... ... @@ -1805,10 +1812,11 @@ function DeleteMemberDialog({
1805 1812 </Button>
1806 1813 <Button
1807 1814 variant="destructive"
1808   - className="min-w-24"
  1815 + className="min-w-24 gap-2"
1809 1816 disabled={submitting}
1810 1817 onClick={submit}
1811 1818 >
  1819 + <Trash2 className="h-4 w-4 shrink-0" />
1812 1820 {submitting ? "Deleting..." : "Delete"}
1813 1821 </Button>
1814 1822 </DialogFooter>
... ...
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
1 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 12 import { Button } from "../ui/button";
4 13 import { Input } from "../ui/input";
5 14 import {
... ... @@ -26,12 +35,20 @@ import {
26 35 SelectValue,
27 36 } from "../ui/select";
28 37 import { Label } from "../ui/label";
  38 +import { ImageUrlUpload } from "../ui/image-url-upload";
  39 +import { resolvePictureUrlForDisplay } from "../../services/imageUploadService";
29 40 import { Switch } from "../ui/switch";
30 41 import { Badge } from "../ui/badge";
31 42 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
32 43 import { toast } from "sonner";
33 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 52 import {
36 53 createProduct,
37 54 deleteProduct,
... ... @@ -46,9 +63,17 @@ import {
46 63 updateProductLocation,
47 64 } from "../../services/productLocationService";
48 65 import type { LocationDto } from "../../types/location";
49   -import type { LabelCategoryDto } from "../../types/labelCategory";
50 66 import type { ProductDto, ProductCreateInput, ProductUpdateInput } from "../../types/product";
  67 +import type { ProductCategoryDto, ProductCategoryCreateInput } from "../../types/productCategory";
51 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 78 function toDisplay(v: string | null | undefined): string {
54 79 const s = (v ?? "").trim();
... ... @@ -101,7 +126,17 @@ export function ProductsView() {
101 126 const [loading, setLoading] = useState(false);
102 127 const [locationMap, setLocationMap] = useState<Map<string, string[]>>(new Map());
103 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 141 const [keyword, setKeyword] = useState("");
107 142 const [debouncedKeyword, setDebouncedKeyword] = useState("");
... ... @@ -116,7 +151,10 @@ export function ProductsView() {
116 151 const abortRef = useRef<AbortController | null>(null);
117 152  
118 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 158 const [editingProduct, setEditingProduct] = useState<ProductDto | null>(null);
121 159 const [deletingProduct, setDeletingProduct] = useState<ProductDto | null>(null);
122 160 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null);
... ... @@ -135,27 +173,37 @@ export function ProductsView() {
135 173 try {
136 174 const [locRes, catRes] = await Promise.all([
137 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 182 if (c) return;
141 183 setLocations(locRes.items ?? []);
142   - setLabelCategories(catRes.items ?? []);
  184 + setProductCategoriesCatalog(catRes.items ?? []);
143 185 } catch {
144 186 if (!c) {
145 187 setLocations([]);
146   - setLabelCategories([]);
  188 + setProductCategoriesCatalog([]);
147 189 }
148 190 }
149 191 })();
150 192 return () => {
151 193 c = true;
152 194 };
153   - }, []);
  195 + }, [catalogReloadToken]);
  196 +
  197 + const reloadCategoryCatalog = () => setCatalogReloadToken((x) => x + 1);
154 198  
155 199 useEffect(() => {
156 200 setPageIndex(1);
157 201 }, [debouncedKeyword, locationFilter, categoryFilter, stateFilter, pageSize]);
158 202  
  203 + useEffect(() => {
  204 + setCatPageIndex(1);
  205 + }, [debouncedKeyword, stateFilter, catPageSize]);
  206 +
159 207 const needClientFilter = locationFilter !== "all" || categoryFilter !== "all";
160 208  
161 209 useEffect(() => {
... ... @@ -236,8 +284,62 @@ export function ProductsView() {
236 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 336 const refresh = () => setRefreshSeq((x) => x + 1);
240 337  
  338 + const refreshCategories = () => {
  339 + setCatRefreshSeq((x) => x + 1);
  340 + reloadCategoryCatalog();
  341 + };
  342 +
241 343 const locationOptions = useMemo(
242 344 () =>
243 345 locations.map((loc) => ({
... ... @@ -249,11 +351,13 @@ export function ProductsView() {
249 351  
250 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 363 const totalPages = Math.max(1, Math.ceil(total / pageSize));
... ... @@ -372,7 +476,10 @@ export function ProductsView() {
372 476 ) : (
373 477 <Button
374 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 484 New Category <Plus className="w-4 h-4" />
378 485 </Button>
... ... @@ -465,7 +572,7 @@ export function ProductsView() {
465 572 <div className="flex items-center gap-2 min-w-0">
466 573 {p.productImageUrl ? (
467 574 <img
468   - src={p.productImageUrl}
  575 + src={resolvePictureUrlForDisplay(p.productImageUrl)}
469 576 alt=""
470 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 630 <Button
524 631 type="button"
525 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 634 onClick={() => {
528 635 setActionsOpenId(null);
529 636 setDeletingProduct(p);
530 637 }}
531 638 >
  639 + <Trash2 className="w-4 h-4 shrink-0" />
532 640 Delete
533 641 </Button>
534 642 </PopoverContent>
... ... @@ -583,7 +691,164 @@ export function ProductsView() {
583 691 </div>
584 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 853 </div>
589 854  
... ... @@ -613,44 +878,28 @@ export function ProductsView() {
613 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 903 </div>
655 904 );
656 905 }
... ... @@ -782,12 +1031,13 @@ function ProductFormDialog({
782 1031 />
783 1032 </div>
784 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 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 1042 </div>
793 1043 <div className="space-y-2">
... ... @@ -862,7 +1112,8 @@ function DeleteProductDialog({
862 1112 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
863 1113 Cancel
864 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 1117 {submitting ? "Deleting…" : "Delete"}
867 1118 </Button>
868 1119 </DialogFooter>
... ... @@ -871,25 +1122,203 @@ function DeleteProductDialog({
871 1122 );
872 1123 }
873 1124  
874   -function CreateCategoryPlaceholderDialog({
  1125 +function ProductCategoryFormDialog({
875 1126 open,
  1127 + category,
876 1128 onOpenChange,
  1129 + onSaved,
877 1130 }: {
878 1131 open: boolean;
  1132 + category: ProductCategoryDto | null;
879 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 1201 return (
882 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 1204 <DialogHeader>
885   - <DialogTitle>Categories</DialogTitle>
  1205 + <DialogTitle>{isEdit ? "Edit Category" : "New Category"}</DialogTitle>
886 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 1208 </DialogDescription>
889 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 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 1322 </Button>
894 1323 </DialogFooter>
895 1324 </DialogContent>
... ...
美国版/Food Labeling Management Platform/src/components/system-menu/SystemMenuView.tsx
... ... @@ -614,7 +614,8 @@ function DeleteSystemMenuDialog({
614 614 <Button className="min-w-24" variant="outline" onClick={() => onOpenChange(false)}>
615 615 Cancel
616 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 619 {submitting ? "Deleting..." : "Delete"}
619 620 </Button>
620 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 18 labelTypeName?: string | null;
19 19 /** 列表接口:模板展示名 */
20 20 templateName?: string | null;
  21 + /** 列表接口:关联产品名称汇总(逗号分隔等,与后端 `products` 一致) */
  22 + products?: string | null;
21 23 /** 列表接口:关联产品展示名(按产品展开时可能为单条) */
22 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`** 字段,否则无法通过邮箱登录。