Commit 3af4878ddc4ae2ea4d2730a60724bfc18c98450c
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) => { |
| 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 '../../utils/authSession' |
| 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 'vue-i18n' |
| 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<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
美国版/Food Labeling Management App UniApp/src/utils/authSession.ts
美国版/Food Labeling Management App UniApp/src/utils/stores.ts
美国版/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 'qrcode.react'; |
| 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 '../../../types/location'; |
| 24 | 24 | import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; |
| 25 | 25 | import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; |
| 26 | 26 | import { Checkbox } from '../../ui/checkbox'; |
| 27 | +import { ImageUrlUpload } from '../../ui/image-url-upload'; | |
| 28 | +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`** 字段,否则无法通过邮箱登录。 |