Commit 6ce074067687675d93bdb206b791a1c1ad34ad4e

Authored by 杨鑫
1 parent 5e90b8f3

提交

Showing 32 changed files with 1270 additions and 1090 deletions
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
@@ -159,7 +159,7 @@ @@ -159,7 +159,7 @@
159 <view class="food-grid"> 159 <view class="food-grid">
160 <view 160 <view
161 v-for="product in pCat.products" 161 v-for="product in pCat.products"
162 - :key="productCardKey(product)" 162 + :key="product.productId"
163 class="food-card" 163 class="food-card"
164 @click="handleProductClick(product, pCat.name)" 164 @click="handleProductClick(product, pCat.name)"
165 > 165 >
@@ -408,16 +408,8 @@ function productPhotoSrc(p: UsAppLabelingProductNodeDto): string { @@ -408,16 +408,8 @@ function productPhotoSrc(p: UsAppLabelingProductNodeDto): string {
408 return resolveMediaUrlForApp(p.productImageUrl) 408 return resolveMediaUrlForApp(p.productImageUrl)
409 } 409 }
410 410
411 -/** 同一 productId 多模板拆卡时保证列表 :key 唯一 */  
412 -function productCardKey(p: UsAppLabelingProductNodeDto): string {  
413 - const tid = (p.templateId ?? '').trim()  
414 - return tid ? `${p.productId}|${tid}` : p.productId  
415 -}  
416 -  
417 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */ 411 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */
418 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string { 412 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string {
419 - const direct = (p.templateLabelSizeText ?? '').trim()  
420 - if (direct) return direct  
421 const types = p.labelTypes || [] 413 const types = p.labelTypes || []
422 if (types.length === 0) return '—' 414 if (types.length === 0) return '—'
423 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean) 415 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean)
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
@@ -40,11 +40,6 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD @@ -40,11 +40,6 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD
40 })) 40 }))
41 return { 41 return {
42 productId: String(x?.productId ?? x?.ProductId ?? ''), 42 productId: String(x?.productId ?? x?.ProductId ?? ''),
43 - templateId: (x?.templateId ?? x?.TemplateId ?? null) as string | null,  
44 - templateCode: (x?.templateCode ?? x?.TemplateCode ?? null) as string | null,  
45 - templateLabelSizeText: (x?.templateLabelSizeText ?? x?.TemplateLabelSizeText ?? null) as  
46 - | string  
47 - | null,  
48 productName: String(x?.productName ?? x?.ProductName ?? ''), 43 productName: String(x?.productName ?? x?.ProductName ?? ''),
49 productCode: String(x?.productCode ?? x?.ProductCode ?? ''), 44 productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
50 productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null, 45 productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null,
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
@@ -11,11 +11,6 @@ export interface UsAppLabelTypeNodeDto { @@ -11,11 +11,6 @@ export interface UsAppLabelTypeNodeDto {
11 11
12 export interface UsAppLabelingProductNodeDto { 12 export interface UsAppLabelingProductNodeDto {
13 productId: string 13 productId: string
14 - /** 与 productId 组合唯一标识一张卡(多模板拆卡) */  
15 - templateId?: string | null  
16 - templateCode?: string | null  
17 - /** 当前卡片模板尺寸,与接口 templateLabelSizeText 对齐 */  
18 - templateLabelSizeText?: string | null  
19 productName: string 14 productName: string
20 productCode: string 15 productCode: string
21 productImageUrl: string | null 16 productImageUrl: string | null
美国版/Food Labeling Management Platform/build/assets/index-ChVLtgeV.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-D3QH2BRm.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
@@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
5 <meta charset="UTF-8" /> 5 <meta charset="UTF-8" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <title>Food Labeling Management Platform</title> 7 <title>Food Labeling Management Platform</title>
8 - <script type="module" crossorigin src="/assets/index-ChVLtgeV.js"></script> 8 + <script type="module" crossorigin src="/assets/index-D3QH2BRm.js"></script>
9 <link rel="stylesheet" crossorigin href="/assets/index-DLL5VTnd.css"> 9 <link rel="stylesheet" crossorigin href="/assets/index-DLL5VTnd.css">
10 </head> 10 </head>
11 11
美国版/Food Labeling Management Platform/src/components/bulk/batch-import-dialog.tsx
1 -import React, { useRef, useState } from "react"; 1 +import React, { useCallback, useId, useRef, useState } from "react";
  2 +import { FileSpreadsheet, Upload, X } from "lucide-react";
2 import { Button } from "../ui/button"; 3 import { Button } from "../ui/button";
3 import { 4 import {
4 Dialog, 5 Dialog,
@@ -9,6 +10,7 @@ import { @@ -9,6 +10,7 @@ import {
9 DialogTitle, 10 DialogTitle,
10 } from "../ui/dialog"; 11 } from "../ui/dialog";
11 import { Label } from "../ui/label"; 12 import { Label } from "../ui/label";
  13 +import { cn } from "../ui/utils";
12 import { toast } from "sonner"; 14 import { toast } from "sonner";
13 import { ApiError } from "../../lib/apiClient"; 15 import { ApiError } from "../../lib/apiClient";
14 16
@@ -24,6 +26,25 @@ export type BatchImportDialogProps = { @@ -24,6 +26,25 @@ export type BatchImportDialogProps = {
24 downloadingTemplate?: boolean; 26 downloadingTemplate?: boolean;
25 }; 27 };
26 28
  29 +const ACCEPT = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
  30 +
  31 +function formatFileSize(bytes: number): string {
  32 + if (!Number.isFinite(bytes) || bytes < 0) return "";
  33 + if (bytes < 1024) return `${bytes} B`;
  34 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  35 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  36 +}
  37 +
  38 +function pickFile(list: FileList | null): File | null {
  39 + const f = list?.[0];
  40 + if (!f) return null;
  41 + const name = f.name.toLowerCase();
  42 + if (!name.endsWith(".xlsx")) {
  43 + return null;
  44 + }
  45 + return f;
  46 +}
  47 +
27 export function BatchImportDialog({ 48 export function BatchImportDialog({
28 open, 49 open,
29 onOpenChange, 50 onOpenChange,
@@ -33,15 +54,42 @@ export function BatchImportDialog({ @@ -33,15 +54,42 @@ export function BatchImportDialog({
33 onImportFile, 54 onImportFile,
34 downloadingTemplate = false, 55 downloadingTemplate = false,
35 }: BatchImportDialogProps) { 56 }: BatchImportDialogProps) {
  57 + const fileInputId = useId();
36 const inputRef = useRef<HTMLInputElement | null>(null); 58 const inputRef = useRef<HTMLInputElement | null>(null);
37 const [file, setFile] = useState<File | null>(null); 59 const [file, setFile] = useState<File | null>(null);
38 const [busy, setBusy] = useState(false); 60 const [busy, setBusy] = useState(false);
  61 + const [dragActive, setDragActive] = useState(false);
39 62
40 const reset = () => { 63 const reset = () => {
41 setFile(null); 64 setFile(null);
  65 + setDragActive(false);
42 if (inputRef.current) inputRef.current.value = ""; 66 if (inputRef.current) inputRef.current.value = "";
43 }; 67 };
44 68
  69 + const clearSelectedFile = () => {
  70 + setFile(null);
  71 + if (inputRef.current) inputRef.current.value = "";
  72 + };
  73 +
  74 + const applyFiles = useCallback((list: FileList | null) => {
  75 + const f = pickFile(list);
  76 + if (list?.length && !f) {
  77 + toast.error("Invalid file", { description: "Please choose an .xlsx file." });
  78 + return;
  79 + }
  80 + setFile(f);
  81 + }, []);
  82 +
  83 + /** 延迟触发:避免嵌在 Radix Dialog 内时,同步 click() 导致部分浏览器不触发 change */
  84 + const openPicker = () => {
  85 + window.setTimeout(() => {
  86 + const el = inputRef.current;
  87 + if (!el) return;
  88 + el.value = "";
  89 + el.click();
  90 + }, 0);
  91 + };
  92 +
45 return ( 93 return (
46 <Dialog 94 <Dialog
47 open={open} 95 open={open}
@@ -50,42 +98,141 @@ export function BatchImportDialog({ @@ -50,42 +98,141 @@ export function BatchImportDialog({
50 onOpenChange(v); 98 onOpenChange(v);
51 }} 99 }}
52 > 100 >
53 - <DialogContent className="sm:max-w-md">  
54 - <DialogHeader>  
55 - <DialogTitle>{title}</DialogTitle>  
56 - {description ? <DialogDescription>{description}</DialogDescription> : null} 101 + <DialogContent
  102 + className={cn("gap-4")}
  103 + style={{
  104 + padding: 20,
  105 + width: "min(50vw, calc(100vw - 2rem))",
  106 + maxWidth: "min(50vw, calc(100vw - 2rem))",
  107 + }}
  108 + >
  109 + <DialogHeader className="space-y-1.5">
  110 + <DialogTitle className="text-base">{title}</DialogTitle>
  111 + {description ? (
  112 + <DialogDescription className="text-xs leading-relaxed">{description}</DialogDescription>
  113 + ) : null}
57 </DialogHeader> 114 </DialogHeader>
58 - <div className="flex flex-col gap-4 py-2"> 115 +
  116 + <div className="flex flex-col gap-3">
59 <div className="space-y-2"> 117 <div className="space-y-2">
60 - <Label htmlFor="batch-import-file">Excel file (.xlsx)</Label> 118 + <Label htmlFor={fileInputId} className="text-sm">
  119 + Excel file (.xlsx)
  120 + </Label>
61 <input 121 <input
62 - id="batch-import-file" 122 + id={fileInputId}
63 ref={inputRef} 123 ref={inputRef}
64 type="file" 124 type="file"
65 - accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"  
66 - className="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border file:border-gray-300 file:bg-white file:px-3 file:py-2 file:text-sm file:font-medium file:text-gray-900 hover:file:bg-gray-50"  
67 - onChange={(e) => {  
68 - const f = e.target.files?.[0] ?? null;  
69 - setFile(f);  
70 - }} 125 + accept={ACCEPT}
  126 + className="sr-only"
  127 + tabIndex={-1}
  128 + aria-label="Choose Excel spreadsheet"
  129 + onChange={(e) => applyFiles(e.target.files)}
71 /> 130 />
  131 + <button
  132 + type="button"
  133 + className={cn(
  134 + "flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-5 text-center transition-colors outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
  135 + dragActive
  136 + ? "border-blue-400 bg-blue-50/80"
  137 + : "border-gray-300 bg-gray-50/90 hover:border-gray-400 hover:bg-gray-50",
  138 + )}
  139 + onClick={openPicker}
  140 + onKeyDown={(e) => {
  141 + if (e.key === "Enter" || e.key === " ") {
  142 + e.preventDefault();
  143 + openPicker();
  144 + }
  145 + }}
  146 + onDragEnter={(e) => {
  147 + e.preventDefault();
  148 + setDragActive(true);
  149 + }}
  150 + onDragOver={(e) => {
  151 + e.preventDefault();
  152 + e.dataTransfer.dropEffect = "copy";
  153 + }}
  154 + onDragLeave={(e) => {
  155 + e.preventDefault();
  156 + if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragActive(false);
  157 + }}
  158 + onDrop={(e) => {
  159 + e.preventDefault();
  160 + setDragActive(false);
  161 + applyFiles(e.dataTransfer.files);
  162 + }}
  163 + >
  164 + <Upload className="h-7 w-7 shrink-0 text-gray-400" aria-hidden />
  165 + <div className="text-sm text-gray-700">
  166 + <span className="font-medium text-blue-700">Browse</span>
  167 + <span className="text-gray-600"> or drop your file here</span>
  168 + </div>
  169 + <p className="text-xs text-gray-500">
  170 + {file ? "Click again to replace the file" : "Only .xlsx is accepted"}
  171 + </p>
  172 + </button>
  173 +
  174 + {file ? (
  175 + <div
  176 + role="status"
  177 + aria-live="polite"
  178 + className="flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50/60 px-3 py-2.5"
  179 + >
  180 + <FileSpreadsheet className="h-8 w-8 shrink-0 text-blue-600" aria-hidden />
  181 + <div className="min-w-0 flex-1">
  182 + <div className="text-xs font-medium uppercase tracking-wide text-blue-800">Selected file</div>
  183 + <div className="truncate text-sm font-semibold text-gray-900" title={file.name}>
  184 + {file.name}
  185 + </div>
  186 + {formatFileSize(file.size) ? (
  187 + <div className="text-xs text-gray-600">{formatFileSize(file.size)}</div>
  188 + ) : null}
  189 + </div>
  190 + <Button
  191 + type="button"
  192 + variant="outline"
  193 + size="sm"
  194 + className="shrink-0 border-gray-300 bg-white px-2 text-gray-700 hover:bg-gray-50"
  195 + onClick={(e) => {
  196 + e.stopPropagation();
  197 + clearSelectedFile();
  198 + }}
  199 + aria-label="Remove selected file"
  200 + >
  201 + <X className="h-4 w-4" aria-hidden />
  202 + <span className="ml-1 hidden sm:inline">Remove</span>
  203 + </Button>
  204 + </div>
  205 + ) : (
  206 + <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/50 px-3 py-2 text-center text-xs text-gray-500">
  207 + No file selected yet — use Browse or drag an .xlsx above
  208 + </div>
  209 + )}
  210 +
  211 + <p className="text-xs text-gray-600">
  212 + After a file is selected, click <span className="font-medium text-gray-900">Import</span> to upload
  213 + it to the server.
  214 + </p>
  215 + </div>
  216 +
  217 + <div className="flex justify-stretch sm:justify-center">
  218 + <Button
  219 + type="button"
  220 + variant="outline"
  221 + className="w-full sm:w-auto"
  222 + size="sm"
  223 + disabled={downloadingTemplate}
  224 + onClick={() => void onDownloadTemplate()}
  225 + >
  226 + {downloadingTemplate ? "Downloading…" : "Download template"}
  227 + </Button>
72 </div> 228 </div>
73 </div> 229 </div>
74 - <div className="flex justify-center pb-2">  
75 - <Button  
76 - type="button"  
77 - variant="outline"  
78 - className="w-full sm:w-auto"  
79 - disabled={downloadingTemplate}  
80 - onClick={() => void onDownloadTemplate()}  
81 - >  
82 - {downloadingTemplate ? "Downloading…" : "Download template"}  
83 - </Button>  
84 - </div> 230 +
85 <DialogFooter className="gap-2 sm:gap-0"> 231 <DialogFooter className="gap-2 sm:gap-0">
86 <Button 232 <Button
87 type="button" 233 type="button"
88 variant="outline" 234 variant="outline"
  235 + size="sm"
89 onClick={() => { 236 onClick={() => {
90 reset(); 237 reset();
91 onOpenChange(false); 238 onOpenChange(false);
@@ -95,6 +242,8 @@ export function BatchImportDialog({ @@ -95,6 +242,8 @@ export function BatchImportDialog({
95 </Button> 242 </Button>
96 <Button 243 <Button
97 type="button" 244 type="button"
  245 + size="sm"
  246 + className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
98 disabled={!file || busy} 247 disabled={!file || busy}
99 onClick={async () => { 248 onClick={async () => {
100 if (!file) return; 249 if (!file) return;
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
@@ -226,7 +226,7 @@ export function LabelCategoriesView() { @@ -226,7 +226,7 @@ export function LabelCategoriesView() {
226 }; 226 };
227 227
228 return ( 228 return (
229 - <div className="h-full flex flex-col"> 229 + <div className="flex h-full min-h-0 flex-col">
230 <div className="pb-4"> 230 <div className="pb-4">
231 <div className="flex flex-col gap-4"> 231 <div className="flex flex-col gap-4">
232 <div className="flex flex-nowrap items-center gap-3"> 232 <div className="flex flex-nowrap items-center gap-3">
@@ -268,20 +268,19 @@ export function LabelCategoriesView() { @@ -268,20 +268,19 @@ export function LabelCategoriesView() {
268 <TableHead className="font-bold text-gray-900 w-[200px]">Category Photo</TableHead> 268 <TableHead className="font-bold text-gray-900 w-[200px]">Category Photo</TableHead>
269 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead> 269 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead>
270 <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead> 270 <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead>
271 - <TableHead className="font-bold text-gray-900">Last Edited</TableHead>  
272 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> 271 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead>
273 </TableRow> 272 </TableRow>
274 </TableHeader> 273 </TableHeader>
275 <TableBody> 274 <TableBody>
276 {loading ? ( 275 {loading ? (
277 <TableRow> 276 <TableRow>
278 - <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> 277 + <TableCell colSpan={6} className="text-center text-sm text-gray-500 py-10">
279 Loading... 278 Loading...
280 </TableCell> 279 </TableCell>
281 </TableRow> 280 </TableRow>
282 ) : categories.length === 0 ? ( 281 ) : categories.length === 0 ? (
283 <TableRow> 282 <TableRow>
284 - <TableCell colSpan={7} className="text-center text-sm text-gray-500 py-10"> 283 + <TableCell colSpan={6} className="text-center text-sm text-gray-500 py-10">
285 No results. 284 No results.
286 </TableCell> 285 </TableCell>
287 </TableRow> 286 </TableRow>
@@ -299,9 +298,6 @@ export function LabelCategoriesView() { @@ -299,9 +298,6 @@ export function LabelCategoriesView() {
299 </Badge> 298 </Badge>
300 </TableCell> 299 </TableCell>
301 <TableCell className="font-numeric">{item.orderNum ?? "None"}</TableCell> 300 <TableCell className="font-numeric">{item.orderNum ?? "None"}</TableCell>
302 - <TableCell className="text-gray-500 tabular-nums font-numeric">  
303 - {item.creationTime ? new Date(item.creationTime).toLocaleString() : "None"}  
304 - </TableCell>  
305 <TableCell className="text-center"> 301 <TableCell className="text-center">
306 <Popover 302 <Popover
307 open={actionsOpenForId === item.id} 303 open={actionsOpenForId === item.id}
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/ElementsPanel.tsx
1 -import React from 'react';  
2 -import { ScrollArea } from '../../ui/scroll-area';  
3 -import type { ElementLibraryCategory, ElementType } from '../../../types/labelTemplate'; 1 +import React from "react";
  2 +import type { ElementLibraryCategory, ElementType } from "../../../types/labelTemplate";
4 3
5 /** 左侧标签库:四类分组(与产品图一致);打印时输入项可带 config 以正确显示为 input */ 4 /** 左侧标签库:四类分组(与产品图一致);打印时输入项可带 config 以正确显示为 input */
6 const ELEMENT_CATEGORIES: { 5 const ELEMENT_CATEGORIES: {
@@ -9,65 +8,65 @@ const ELEMENT_CATEGORIES: { @@ -9,65 +8,65 @@ const ELEMENT_CATEGORIES: {
9 items: { label: string; type: ElementType; config?: Record<string, unknown> }[]; 8 items: { label: string; type: ElementType; config?: Record<string, unknown> }[];
10 }[] = [ 9 }[] = [
11 { 10 {
12 - title: 'Template', 11 + title: "Template",
13 items: [ 12 items: [
14 - { label: 'Text', type: 'TEXT_STATIC' },  
15 - { label: 'QR Code', type: 'QRCODE' },  
16 - { label: 'Barcode', type: 'BARCODE' },  
17 - { label: 'Blank Space', type: 'BLANK' },  
18 - { label: 'Price', type: 'TEXT_PRICE' },  
19 - { label: 'Image', type: 'IMAGE' },  
20 - { label: 'Logo', type: 'IMAGE' }, 13 + { label: "Text", type: "TEXT_STATIC" },
  14 + { label: "QR Code", type: "QRCODE" },
  15 + { label: "Barcode", type: "BARCODE" },
  16 + { label: "Blank Space", type: "BLANK" },
  17 + { label: "Price", type: "TEXT_PRICE" },
  18 + { label: "Image", type: "IMAGE" },
  19 + { label: "Logo", type: "IMAGE" },
21 ], 20 ],
22 }, 21 },
23 { 22 {
24 - title: 'Label', 23 + title: "Label",
25 items: [ 24 items: [
26 - { label: 'Label Name', type: 'TEXT_PRODUCT' },  
27 - { label: 'Text', type: 'TEXT_STATIC' },  
28 - { label: 'QR Code', type: 'QRCODE' },  
29 - { label: 'Barcode', type: 'BARCODE' },  
30 - { label: 'Nutrition Facts', type: 'NUTRITION' },  
31 - { label: 'Price', type: 'TEXT_PRICE' },  
32 - { label: 'Duration Date', type: 'DATE' },  
33 - { label: 'Duration Time', type: 'TIME' },  
34 - { label: 'Duration', type: 'DURATION' },  
35 - { label: 'Image', type: 'IMAGE' },  
36 - { label: 'Label Type', type: 'TEXT_STATIC' },  
37 - { label: 'How-to', type: 'TEXT_STATIC' },  
38 - { label: 'Expiration Alert', type: 'TEXT_STATIC' }, 25 + { label: "Label Name", type: "TEXT_PRODUCT" },
  26 + { label: "Text", type: "TEXT_STATIC" },
  27 + { label: "QR Code", type: "QRCODE" },
  28 + { label: "Barcode", type: "BARCODE" },
  29 + { label: "Nutrition Facts", type: "NUTRITION" },
  30 + { label: "Price", type: "TEXT_PRICE" },
  31 + { label: "Duration Date", type: "DATE" },
  32 + { label: "Duration Time", type: "TIME" },
  33 + { label: "Duration", type: "DURATION" },
  34 + { label: "Image", type: "IMAGE" },
  35 + { label: "Label Type", type: "TEXT_STATIC" },
  36 + { label: "How-to", type: "TEXT_STATIC" },
  37 + { label: "Expiration Alert", type: "TEXT_STATIC" },
39 ], 38 ],
40 }, 39 },
41 { 40 {
42 - title: 'Auto-generated', 41 + title: "Auto-generated",
43 items: [ 42 items: [
44 - { label: 'Company', type: 'TEXT_STATIC' },  
45 - { label: 'Employee', type: 'TEXT_STATIC' },  
46 - { label: 'Current Date', type: 'DATE' },  
47 - { label: 'Current Time', type: 'TIME' },  
48 - { label: 'Label ID', type: 'TEXT_STATIC' }, 43 + { label: "Company", type: "TEXT_STATIC" },
  44 + { label: "Employee", type: "TEXT_STATIC" },
  45 + { label: "Current Date", type: "DATE" },
  46 + { label: "Current Time", type: "TIME" },
  47 + { label: "Label ID", type: "TEXT_STATIC" },
49 ], 48 ],
50 }, 49 },
51 { 50 {
52 - title: 'Print input',  
53 - subtitle: 'Click to add to canvas', 51 + title: "Print input",
  52 + subtitle: "Click to add to canvas",
54 items: [ 53 items: [
55 - { label: 'Text', type: 'TEXT_STATIC', config: { inputType: 'text' } },  
56 - { label: 'Weight', type: 'WEIGHT' },  
57 - { label: 'Number', type: 'TEXT_STATIC', config: { inputType: 'number', text: '0' } },  
58 - { label: 'Date & Time', type: 'DATE', config: { inputType: 'datetime', format: 'YYYY-MM-DD HH:mm' } }, 54 + { label: "Text", type: "TEXT_STATIC", config: { inputType: "text" } },
  55 + { label: "Weight", type: "WEIGHT" },
  56 + { label: "Number", type: "TEXT_STATIC", config: { inputType: "number", text: "0" } },
  57 + { label: "Date & Time", type: "DATE", config: { inputType: "datetime", format: "YYYY-MM-DD HH:mm" } },
59 { 58 {
60 - label: 'Multiple Options',  
61 - type: 'TEXT_STATIC', 59 + label: "Multiple Options",
  60 + type: "TEXT_STATIC",
62 config: { 61 config: {
63 - inputType: 'options',  
64 - multipleOptionId: '', 62 + inputType: "options",
  63 + multipleOptionId: "",
65 selectedOptionValues: [], 64 selectedOptionValues: [],
66 - text: 'Text',  
67 - fontFamily: 'Arial', 65 + text: "Text",
  66 + fontFamily: "Arial",
68 fontSize: 14, 67 fontSize: 14,
69 - fontWeight: 'normal',  
70 - textAlign: 'left', 68 + fontWeight: "normal",
  69 + textAlign: "left",
71 }, 70 },
72 }, 71 },
73 ], 72 ],
@@ -125,45 +124,41 @@ function sectionSurfaceStyle(title: string): React.CSSProperties { @@ -125,45 +124,41 @@ function sectionSurfaceStyle(title: string): React.CSSProperties {
125 }; 124 };
126 } 125 }
127 126
  127 +/**
  128 + * 左侧元素库。纵向滚动由父级(index 左侧列容器)的 overflow-y:auto 负责,
  129 + * 避免预编译 CSS 缺少 min-h-0 / flex-1 时内层滚动高度为 0 导致底部橙色区被裁切。
  130 + */
128 export function ElementsPanel({ onAddElement }: ElementsPanelProps) { 131 export function ElementsPanel({ onAddElement }: ElementsPanelProps) {
129 return ( 132 return (
130 - <div className="w-44 shrink-0 border-r border-slate-200 bg-slate-50 flex flex-col h-full min-h-0"> 133 + <div className="border-r border-slate-200 bg-slate-50">
131 <div 134 <div
132 - className="px-2 py-2 border-b border-slate-200 font-semibold text-slate-800 text-sm shrink-0" 135 + className="border-b border-slate-200 px-2 py-2 text-sm font-semibold text-slate-800"
133 style={{ backgroundColor: "#eff6ff", borderBottomColor: "#93c5fd" }} 136 style={{ backgroundColor: "#eff6ff", borderBottomColor: "#93c5fd" }}
134 > 137 >
135 Elements 138 Elements
136 </div> 139 </div>
137 - <ScrollArea className="flex-1 min-h-0 [&_[data-slot=scroll-area-viewport]]:bg-transparent">  
138 - <div className="p-2 space-y-3">  
139 - {ELEMENT_CATEGORIES.map((cat) => (  
140 - <div key={cat.title} style={sectionSurfaceStyle(cat.title)}>  
141 - <div className="px-2 py-1 text-xs font-medium text-gray-600 uppercase tracking-wide">  
142 - {cat.title}  
143 - </div>  
144 - {cat.subtitle && (  
145 - <div className="px-2 py-0.5 text-[10px] text-gray-400">  
146 - {cat.subtitle}  
147 - </div>  
148 - )}  
149 - <div className="grid grid-cols-2 gap-1 mt-0.5">  
150 - {cat.items.map((item, i) => (  
151 - <button  
152 - key={`${cat.title}-${item.label}-${i}`}  
153 - type="button"  
154 - onClick={() =>  
155 - onAddElement(item.type, item.config, cat.title, item.label)  
156 - }  
157 - className="text-left px-2 py-1 text-xs rounded hover:bg-gray-100 border border-transparent hover:border-gray-200 truncate"  
158 - >  
159 - {item.label}  
160 - </button>  
161 - ))}  
162 - </div> 140 + <div className="p-2 space-y-3">
  141 + {ELEMENT_CATEGORIES.map((cat) => (
  142 + <div key={cat.title} style={sectionSurfaceStyle(cat.title)}>
  143 + <div className="px-2 py-1 text-xs font-medium uppercase tracking-wide text-gray-600">{cat.title}</div>
  144 + {cat.subtitle ? (
  145 + <div className="px-2 py-0.5 text-[10px] text-gray-400">{cat.subtitle}</div>
  146 + ) : null}
  147 + <div className="mt-0.5 grid grid-cols-2 gap-1">
  148 + {cat.items.map((item, i) => (
  149 + <button
  150 + key={`${cat.title}-${item.label}-${i}`}
  151 + type="button"
  152 + onClick={() => onAddElement(item.type, item.config, cat.title, item.label)}
  153 + className="items-start justify-start whitespace-normal break-words rounded border border-transparent px-2 py-1.5 text-left text-xs leading-snug hover:border-gray-200 hover:bg-gray-100"
  154 + >
  155 + {item.label}
  156 + </button>
  157 + ))}
163 </div> 158 </div>
164 - ))}  
165 - </div>  
166 - </ScrollArea> 159 + </div>
  160 + ))}
  161 + </div>
167 </div> 162 </div>
168 ); 163 );
169 } 164 }
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
@@ -1450,7 +1450,16 @@ export function LabelCanvas({ @@ -1450,7 +1450,16 @@ export function LabelCanvas({
1450 }; 1450 };
1451 1451
1452 return ( 1452 return (
1453 - <div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-gray-100"> 1453 + <div
  1454 + className="flex flex-col overflow-hidden bg-gray-100"
  1455 + style={{
  1456 + flex: "1 1 0%",
  1457 + minHeight: 0,
  1458 + display: "flex",
  1459 + flexDirection: "column",
  1460 + overflow: "hidden",
  1461 + }}
  1462 + >
1454 {/* Label Preview 标题 + 网格/预览/缩放 */} 1463 {/* Label Preview 标题 + 网格/预览/缩放 */}
1455 <div className="shrink-0 px-4 py-2 border-b border-gray-200 bg-white flex flex-nowrap items-center justify-between gap-3 z-10 min-h-[44px]"> 1464 <div className="shrink-0 px-4 py-2 border-b border-gray-200 bg-white flex flex-nowrap items-center justify-between gap-3 z-10 min-h-[44px]">
1456 <span className="text-sm font-medium text-gray-700 shrink-0 min-w-0 truncate">Label Preview</span> 1465 <span className="text-sm font-medium text-gray-700 shrink-0 min-w-0 truncate">Label Preview</span>
@@ -1571,9 +1580,10 @@ export function LabelCanvas({ @@ -1571,9 +1580,10 @@ export function LabelCanvas({
1571 <div 1580 <div
1572 ref={scrollContainerRef} 1581 ref={scrollContainerRef}
1573 className={cn( 1582 className={cn(
1574 - "flex-1 min-h-0 overflow-auto relative", 1583 + "overflow-auto relative",
1575 isSpacePressed ? "cursor-grab active:cursor-grabbing" : "" 1584 isSpacePressed ? "cursor-grab active:cursor-grabbing" : ""
1576 )} 1585 )}
  1586 + style={{ flex: "1 1 0%", minHeight: 0, overflow: "auto", position: "relative" }}
1577 onPointerDown={handleContainerPointerDown} 1587 onPointerDown={handleContainerPointerDown}
1578 onPointerMove={handleContainerPointerMove} 1588 onPointerMove={handleContainerPointerMove}
1579 onPointerUp={handleContainerPointerUp} 1589 onPointerUp={handleContainerPointerUp}
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
1 import React, { useEffect, useState } from 'react'; 1 import React, { useEffect, useState } from 'react';
2 -import { ScrollArea } from '../../ui/scroll-area';  
3 import { Input } from '../../ui/input'; 2 import { Input } from '../../ui/input';
4 import { Button } from '../../ui/button'; 3 import { Button } from '../../ui/button';
5 import { Label } from '../../ui/label'; 4 import { Label } from '../../ui/label';
@@ -82,12 +81,12 @@ export function PropertiesPanel({ @@ -82,12 +81,12 @@ export function PropertiesPanel({
82 if (selectedElement) { 81 if (selectedElement) {
83 const isBlankElement = isBlankSpaceElement(selectedElement); 82 const isBlankElement = isBlankSpaceElement(selectedElement);
84 return ( 83 return (
85 - <div className="w-72 shrink-0 border-l border-gray-200 bg-white flex flex-col h-full">  
86 - <div className="px-3 py-2 border-b border-gray-200 font-semibold text-gray-800"> 84 + <div className="flex h-full min-h-0 w-full min-w-0 flex-col border-l border-gray-200 bg-white">
  85 + <div className="shrink-0 border-b border-gray-200 px-3 py-2 font-semibold text-gray-800">
87 Properties (Element) 86 Properties (Element)
88 </div> 87 </div>
89 - <ScrollArea className="flex-1">  
90 - <div className="p-3 space-y-3"> 88 + <div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain">
  89 + <div className="space-y-3 p-3">
91 <div className="grid grid-cols-2 gap-2"> 90 <div className="grid grid-cols-2 gap-2">
92 <div> 91 <div>
93 <Label className="text-xs">X</Label> 92 <Label className="text-xs">X</Label>
@@ -218,23 +217,23 @@ export function PropertiesPanel({ @@ -218,23 +217,23 @@ export function PropertiesPanel({
218 </div> 217 </div>
219 )} 218 )}
220 </div> 219 </div>
221 - </ScrollArea> 220 + </div>
222 </div> 221 </div>
223 ); 222 );
224 } 223 }
225 224
226 return ( 225 return (
227 - <div className="w-72 shrink-0 border-l border-gray-200 bg-white flex flex-col h-full">  
228 - <div className="px-3 py-2 border-b border-gray-200 font-semibold text-gray-800"> 226 + <div className="flex h-full min-h-0 w-full min-w-0 flex-col border-l border-gray-200 bg-white">
  227 + <div className="shrink-0 border-b border-gray-200 px-3 py-2 font-semibold text-gray-800">
229 Properties (Element) 228 Properties (Element)
230 </div> 229 </div>
231 - <ScrollArea className="flex-1"> 230 + <div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain">
232 <div className="p-3"> 231 <div className="p-3">
233 <div className="rounded-md border border-blue-100 bg-blue-50/50 p-3 text-xs text-blue-900"> 232 <div className="rounded-md border border-blue-100 bg-blue-50/50 p-3 text-xs text-blue-900">
234 Select an element on the canvas to edit its properties. 233 Select an element on the canvas to edit its properties.
235 </div> 234 </div>
236 </div> 235 </div>
237 - </ScrollArea> 236 + </div>
238 </div> 237 </div>
239 ); 238 );
240 } 239 }
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
@@ -407,7 +407,16 @@ export function LabelTemplateEditor({ @@ -407,7 +407,16 @@ export function LabelTemplateEditor({
407 }, [template]); 407 }, [template]);
408 408
409 return ( 409 return (
410 - <div className="flex flex-col h-full min-h-0"> 410 + <div
  411 + className="flex flex-col overflow-hidden"
  412 + style={{
  413 + flex: "1 1 0%",
  414 + minHeight: 0,
  415 + display: "flex",
  416 + flexDirection: "column",
  417 + overflow: "hidden",
  418 + }}
  419 + >
411 {/* Toolbar */} 420 {/* Toolbar */}
412 <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 bg-white shrink-0"> 421 <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 bg-white shrink-0">
413 <Button variant="outline" size="sm" onClick={onClose}> 422 <Button variant="outline" size="sm" onClick={onClose}>
@@ -591,12 +600,44 @@ export function LabelTemplateEditor({ @@ -591,12 +600,44 @@ export function LabelTemplateEditor({
591 </div> 600 </div>
592 </div> 601 </div>
593 602
594 - {/* Three columns */}  
595 - <div className="flex flex-1 min-h-0 gap-2 p-2 bg-[#dde7f5]">  
596 - <div className="shrink-0 rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1 overflow-hidden"> 603 + {/* 三列:行布局内联;左侧整列 overflow-y:auto,保证橙色 Print input 可滚到 */}
  604 + <div
  605 + className="gap-2 bg-[#dde7f5] p-2"
  606 + style={{
  607 + flex: "1 1 0%",
  608 + minHeight: 0,
  609 + display: "flex",
  610 + flexDirection: "row",
  611 + alignItems: "stretch",
  612 + gap: 8,
  613 + }}
  614 + >
  615 + <div
  616 + className="shrink-0 rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1"
  617 + style={{
  618 + width: "15rem",
  619 + flexShrink: 0,
  620 + alignSelf: "stretch",
  621 + minHeight: 0,
  622 + overflowX: "hidden",
  623 + overflowY: "auto",
  624 + WebkitOverflowScrolling: "touch",
  625 + boxSizing: "border-box",
  626 + }}
  627 + >
597 <ElementsPanel onAddElement={addElement} /> 628 <ElementsPanel onAddElement={addElement} />
598 </div> 629 </div>
599 - <div className="flex flex-1 min-w-0 min-h-0 flex-col rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1 overflow-hidden"> 630 + <div
  631 + className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1"
  632 + style={{
  633 + flex: "1 1 0%",
  634 + minHeight: 0,
  635 + minWidth: 0,
  636 + overflow: "hidden",
  637 + display: "flex",
  638 + flexDirection: "column",
  639 + }}
  640 + >
600 <LabelCanvas 641 <LabelCanvas
601 template={template} 642 template={template}
602 canvasBorder={canvasBorder} 643 canvasBorder={canvasBorder}
@@ -613,19 +654,18 @@ export function LabelTemplateEditor({ @@ -613,19 +654,18 @@ export function LabelTemplateEditor({
613 hideToolbarPresetSize 654 hideToolbarPresetSize
614 /> 655 />
615 </div> 656 </div>
616 - <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>  
617 - <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 overflow-hidden flex flex-col">  
618 - <DialogHeader className="shrink-0 px-6 py-4 border-b bg-white">  
619 - <DialogTitle>Label preview</DialogTitle>  
620 - </DialogHeader>  
621 - <div className="flex-1 min-h-0 overflow-x-auto overflow-y-auto p-4 bg-gray-50">  
622 - <div className="min-w-max">  
623 - <LabelPreviewOnly template={template} canvasBorder={canvasBorder} maxWidth={0} />  
624 - </div>  
625 - </div>  
626 - </DialogContent>  
627 - </Dialog>  
628 - <div className="shrink-0 rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1 overflow-hidden"> 657 + <div
  658 + className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1"
  659 + style={{
  660 + width: "18rem",
  661 + flexShrink: 0,
  662 + alignSelf: "stretch",
  663 + minHeight: 0,
  664 + overflow: "hidden",
  665 + display: "flex",
  666 + flexDirection: "column",
  667 + }}
  668 + >
629 <PropertiesPanel 669 <PropertiesPanel
630 template={template} 670 template={template}
631 selectedElement={selectedElement} 671 selectedElement={selectedElement}
@@ -636,6 +676,18 @@ export function LabelTemplateEditor({ @@ -636,6 +676,18 @@ export function LabelTemplateEditor({
636 /> 676 />
637 </div> 677 </div>
638 </div> 678 </div>
  679 + <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
  680 + <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 overflow-hidden flex flex-col">
  681 + <DialogHeader className="shrink-0 px-6 py-4 border-b bg-white">
  682 + <DialogTitle>Label preview</DialogTitle>
  683 + </DialogHeader>
  684 + <div className="flex-1 min-h-0 overflow-x-auto overflow-y-auto p-4 bg-gray-50">
  685 + <div className="min-w-max">
  686 + <LabelPreviewOnly template={template} canvasBorder={canvasBorder} maxWidth={0} />
  687 + </div>
  688 + </div>
  689 + </DialogContent>
  690 + </Dialog>
639 </div> 691 </div>
640 ); 692 );
641 } 693 }
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
@@ -479,9 +479,21 @@ export function LabelTemplatesView({ onTemplateEditorOverlayChange }: LabelTempl @@ -479,9 +479,21 @@ export function LabelTemplatesView({ onTemplateEditorOverlayChange }: LabelTempl
479 ); 479 );
480 480
481 return ( 481 return (
482 - <div className="flex h-full min-h-0 flex-col">  
483 - {viewMode === 'editor' ? (  
484 - <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> 482 + <div
  483 + className="flex h-full flex-col"
  484 + style={{ height: "100%", minHeight: 0, display: "flex", flexDirection: "column" }}
  485 + >
  486 + {viewMode === "editor" ? (
  487 + <div
  488 + className="flex flex-1 flex-col overflow-hidden"
  489 + style={{
  490 + flex: "1 1 0%",
  491 + minHeight: 0,
  492 + overflow: "hidden",
  493 + display: "flex",
  494 + flexDirection: "column",
  495 + }}
  496 + >
485 <LabelTemplateEditor 497 <LabelTemplateEditor
486 templateId={editingTemplateId} 498 templateId={editingTemplateId}
487 initialTemplate={initialTemplate} 499 initialTemplate={initialTemplate}
@@ -490,7 +502,9 @@ export function LabelTemplatesView({ onTemplateEditorOverlayChange }: LabelTempl @@ -490,7 +502,9 @@ export function LabelTemplatesView({ onTemplateEditorOverlayChange }: LabelTempl
490 /> 502 />
491 </div> 503 </div>
492 ) : ( 504 ) : (
493 - <div className="flex min-h-0 flex-1 flex-col">{listPanel}</div> 505 + <div className="flex flex-1 flex-col" style={{ flex: "1 1 0%", minHeight: 0 }}>
  506 + {listPanel}
  507 + </div>
494 )} 508 )}
495 509
496 <DeleteLabelTemplateDialog 510 <DeleteLabelTemplateDialog
美国版/Food Labeling Management Platform/src/components/labels/LabelTypesView.tsx
@@ -145,7 +145,7 @@ export function LabelTypesView() { @@ -145,7 +145,7 @@ export function LabelTypesView() {
145 }; 145 };
146 146
147 return ( 147 return (
148 - <div className="h-full flex flex-col"> 148 + <div className="flex h-full min-h-0 flex-col">
149 <div className="pb-4"> 149 <div className="pb-4">
150 <div className="flex flex-col gap-4"> 150 <div className="flex flex-col gap-4">
151 <div className="flex flex-nowrap items-center gap-3"> 151 <div className="flex flex-nowrap items-center gap-3">
@@ -186,20 +186,19 @@ export function LabelTypesView() { @@ -186,20 +186,19 @@ export function LabelTypesView() {
186 <TableHead className="font-bold text-gray-900 w-[200px]">Type Code</TableHead> 186 <TableHead className="font-bold text-gray-900 w-[200px]">Type Code</TableHead>
187 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead> 187 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead>
188 <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead> 188 <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead>
189 - <TableHead className="font-bold text-gray-900">Last Edited</TableHead>  
190 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> 189 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead>
191 </TableRow> 190 </TableRow>
192 </TableHeader> 191 </TableHeader>
193 <TableBody> 192 <TableBody>
194 {loading ? ( 193 {loading ? (
195 <TableRow> 194 <TableRow>
196 - <TableCell colSpan={6} className="text-center text-sm text-gray-500 py-10"> 195 + <TableCell colSpan={5} className="text-center text-sm text-gray-500 py-10">
197 Loading... 196 Loading...
198 </TableCell> 197 </TableCell>
199 </TableRow> 198 </TableRow>
200 ) : types.length === 0 ? ( 199 ) : types.length === 0 ? (
201 <TableRow> 200 <TableRow>
202 - <TableCell colSpan={6} className="text-center text-sm text-gray-500 py-10"> 201 + <TableCell colSpan={5} className="text-center text-sm text-gray-500 py-10">
203 No results. 202 No results.
204 </TableCell> 203 </TableCell>
205 </TableRow> 204 </TableRow>
@@ -214,9 +213,6 @@ export function LabelTypesView() { @@ -214,9 +213,6 @@ export function LabelTypesView() {
214 </Badge> 213 </Badge>
215 </TableCell> 214 </TableCell>
216 <TableCell className="font-numeric">{item.orderNum ?? "None"}</TableCell> 215 <TableCell className="font-numeric">{item.orderNum ?? "None"}</TableCell>
217 - <TableCell className="text-gray-500 tabular-nums font-numeric">  
218 - {item.creationTime ? new Date(item.creationTime).toLocaleString() : "None"}  
219 - </TableCell>  
220 <TableCell className="text-center"> 216 <TableCell className="text-center">
221 <Popover 217 <Popover
222 open={actionsOpenForId === item.id} 218 open={actionsOpenForId === item.id}
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
@@ -108,21 +108,6 @@ function labelRowProductsText(item: LabelDto): string { @@ -108,21 +108,6 @@ function labelRowProductsText(item: LabelDto): string {
108 return "None"; 108 return "None";
109 } 109 }
110 110
111 -/** 列表行:最后编辑时间 */  
112 -function labelRowLastEdited(item: LabelDto): string {  
113 - const le = (item.lastEdited ?? "").trim();  
114 - if (le) return le;  
115 - const ct = item.creationTime;  
116 - if (ct) {  
117 - try {  
118 - return new Date(ct).toLocaleString();  
119 - } catch {  
120 - return String(ct);  
121 - }  
122 - }  
123 - return "None";  
124 -}  
125 -  
126 /** 详情 / 列表行 → 编辑表单(列表接口可能缺 ID 字段,需再以 GET 详情补全) */ 111 /** 详情 / 列表行 → 编辑表单(列表接口可能缺 ID 字段,需再以 GET 详情补全) */
127 function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput { 112 function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput {
128 const ids = d.productIds; 113 const ids = d.productIds;
@@ -733,20 +718,19 @@ export function LabelsList({ openCreateSeq = 0, onOpenCreateIntentConsumed }: La @@ -733,20 +718,19 @@ export function LabelsList({ openCreateSeq = 0, onOpenCreateIntentConsumed }: La
733 <TableHead className="font-bold text-gray-900 w-[120px]">Template</TableHead> 718 <TableHead className="font-bold text-gray-900 w-[120px]">Template</TableHead>
734 <TableHead className="font-bold text-gray-900">Products</TableHead> 719 <TableHead className="font-bold text-gray-900">Products</TableHead>
735 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead> 720 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead>
736 - <TableHead className="font-bold text-gray-900">Last Edited</TableHead>  
737 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead> 721 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead>
738 </TableRow> 722 </TableRow>
739 </TableHeader> 723 </TableHeader>
740 <TableBody> 724 <TableBody>
741 {loading ? ( 725 {loading ? (
742 <TableRow> 726 <TableRow>
743 - <TableCell colSpan={10} className="text-center text-sm text-gray-500 py-10"> 727 + <TableCell colSpan={9} className="text-center text-sm text-gray-500 py-10">
744 Loading... 728 Loading...
745 </TableCell> 729 </TableCell>
746 </TableRow> 730 </TableRow>
747 ) : labels.length === 0 ? ( 731 ) : labels.length === 0 ? (
748 <TableRow> 732 <TableRow>
749 - <TableCell colSpan={10} className="text-center text-sm text-gray-500 py-10"> 733 + <TableCell colSpan={9} className="text-center text-sm text-gray-500 py-10">
750 No results. 734 No results.
751 </TableCell> 735 </TableCell>
752 </TableRow> 736 </TableRow>
@@ -773,9 +757,6 @@ export function LabelsList({ openCreateSeq = 0, onOpenCreateIntentConsumed }: La @@ -773,9 +757,6 @@ export function LabelsList({ openCreateSeq = 0, onOpenCreateIntentConsumed }: La
773 {item.state === true ? "Active" : "Inactive"} 757 {item.state === true ? "Active" : "Inactive"}
774 </Badge> 758 </Badge>
775 </TableCell> 759 </TableCell>
776 - <TableCell className="text-gray-500 tabular-nums font-numeric whitespace-nowrap">  
777 - {labelRowLastEdited(item)}  
778 - </TableCell>  
779 <TableCell className="text-center"> 760 <TableCell className="text-center">
780 <Popover 761 <Popover
781 open={actionsOpenForId === item.id} 762 open={actionsOpenForId === item.id}
美国版/Food Labeling Management Platform/src/components/labels/LabelsView.tsx
@@ -50,7 +50,8 @@ export function LabelsView({ @@ -50,7 +50,8 @@ export function LabelsView({
50 50
51 return ( 51 return (
52 <div 52 <div
53 - className={`flex h-full min-h-0 flex-col ${templateEditorHidesTabs ? 'gap-0' : 'gap-6'}`} 53 + className={`flex h-full flex-col ${templateEditorHidesTabs ? "gap-0" : "gap-6"}`}
  54 + style={{ minHeight: 0, height: "100%", display: "flex", flexDirection: "column" }}
54 > 55 >
55 {/* Tabs:模板编辑器全屏编辑时隐藏 */} 56 {/* Tabs:模板编辑器全屏编辑时隐藏 */}
56 {!templateEditorHidesTabs && ( 57 {!templateEditorHidesTabs && (
@@ -78,8 +79,11 @@ export function LabelsView({ @@ -78,8 +79,11 @@ export function LabelsView({
78 </div> 79 </div>
79 )} 80 )}
80 81
81 - {/* Content:Label Templates 编辑态需占满主区域高度,故用 flex-1 + min-h-0 传递 */}  
82 - <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> 82 + {/* min-h-0 未进预编译 CSS,用内联保证模板编辑区能拿到剩余高度 */}
  83 + <div
  84 + className="flex flex-1 flex-col overflow-hidden"
  85 + style={{ flex: "1 1 0%", minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column" }}
  86 + >
83 {currentView === 'Labels' && ( 87 {currentView === 'Labels' && (
84 <div className="min-h-0 flex-1 overflow-auto"> 88 <div className="min-h-0 flex-1 overflow-auto">
85 <LabelsList 89 <LabelsList
@@ -98,8 +102,11 @@ export function LabelsView({ @@ -98,8 +102,11 @@ export function LabelsView({
98 <LabelTypesView /> 102 <LabelTypesView />
99 </div> 103 </div>
100 )} 104 )}
101 - {currentView === 'Label Templates' && (  
102 - <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> 105 + {currentView === "Label Templates" && (
  106 + <div
  107 + className="flex flex-1 flex-col overflow-hidden"
  108 + style={{ flex: "1 1 0%", minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}
  109 + >
103 <LabelTemplatesView onTemplateEditorOverlayChange={handleTemplateEditorOverlay} /> 110 <LabelTemplatesView onTemplateEditorOverlayChange={handleTemplateEditorOverlay} />
104 </div> 111 </div>
105 )} 112 )}
美国版/Food Labeling Management Platform/src/components/layout/Layout.tsx
@@ -27,7 +27,17 @@ export function Layout({ @@ -27,7 +27,17 @@ export function Layout({
27 {!hideAppChrome && ( 27 {!hideAppChrome && (
28 <Sidebar currentView={currentView} setCurrentView={setCurrentView} menus={menus} onLogout={onLogout} /> 28 <Sidebar currentView={currentView} setCurrentView={setCurrentView} menus={menus} onLogout={onLogout} />
29 )} 29 )}
30 - <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> 30 + <div
  31 + className="flex min-w-0 flex-1 flex-col overflow-hidden"
  32 + style={{
  33 + flex: "1 1 0%",
  34 + minHeight: 0,
  35 + minWidth: 0,
  36 + display: "flex",
  37 + flexDirection: "column",
  38 + overflow: "hidden",
  39 + }}
  40 + >
31 {!hideAppChrome && ( 41 {!hideAppChrome && (
32 <> 42 <>
33 <Header title={currentView} onSettingsClick={() => setCurrentView('Support')} /> 43 <Header title={currentView} onSettingsClick={() => setCurrentView('Support')} />
@@ -49,11 +59,35 @@ export function Layout({ @@ -49,11 +59,35 @@ export function Layout({
49 <main 59 <main
50 className={ 60 className={
51 hideAppChrome 61 hideAppChrome
52 - ? 'flex min-h-0 flex-1 flex-col overflow-hidden p-0'  
53 - : 'min-h-0 flex-1 overflow-y-auto p-8' 62 + ? "flex flex-1 flex-col overflow-hidden p-0"
  63 + : "flex-1 overflow-y-auto p-8"
  64 + }
  65 + style={
  66 + hideAppChrome
  67 + ? {
  68 + flex: "1 1 0%",
  69 + minHeight: 0,
  70 + display: "flex",
  71 + flexDirection: "column",
  72 + overflow: "hidden",
  73 + padding: 0,
  74 + }
  75 + : { flex: "1 1 0%", minHeight: 0, minWidth: 0 }
54 } 76 }
55 > 77 >
56 - <div className="flex h-full min-h-0 w-full flex-col">{children}</div> 78 + <div
  79 + className="flex w-full min-w-0 flex-1 flex-col"
  80 + style={{
  81 + flex: "1 1 0%",
  82 + minHeight: 0,
  83 + minWidth: 0,
  84 + width: "100%",
  85 + display: "flex",
  86 + flexDirection: "column",
  87 + }}
  88 + >
  89 + {children}
  90 + </div>
57 </main> 91 </main>
58 </div> 92 </div>
59 </div> 93 </div>
美国版/Food Labeling Management Platform/src/components/locations/LocationsView.tsx
@@ -55,7 +55,8 @@ import type { LocationCreateInput, LocationDto } from &quot;../../types/location&quot;; @@ -55,7 +55,8 @@ import type { LocationCreateInput, LocationDto } from &quot;../../types/location&quot;;
55 import type { GroupListItem } from "../../types/group"; 55 import type { GroupListItem } from "../../types/group";
56 import type { PartnerListItem } from "../../types/partner"; 56 import type { PartnerListItem } from "../../types/partner";
57 import { BatchImportDialog } from "../bulk/batch-import-dialog"; 57 import { BatchImportDialog } from "../bulk/batch-import-dialog";
58 -import { LocationBulkEditDialog } from "./location-bulk-edit-dialog"; 58 +import { LocationBulkEditPage } from "./location-bulk-edit-page";
  59 +import { cn } from "../ui/utils";
59 60
60 const LOCATION_PG_NONE = "__none__"; 61 const LOCATION_PG_NONE = "__none__";
61 62
@@ -149,7 +150,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -149,7 +150,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
149 const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null); 150 const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null);
150 const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set()); 151 const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
151 const [bulkImportOpen, setBulkImportOpen] = useState(false); 152 const [bulkImportOpen, setBulkImportOpen] = useState(false);
152 - const [bulkEditOpen, setBulkEditOpen] = useState(false); 153 + const [locationBulkEditPage, setLocationBulkEditPage] = useState(false);
153 const [bulkEditSeed, setBulkEditSeed] = useState<LocationDto[]>([]); 154 const [bulkEditSeed, setBulkEditSeed] = useState<LocationDto[]>([]);
154 const [tmplDownloading, setTmplDownloading] = useState(false); 155 const [tmplDownloading, setTmplDownloading] = useState(false);
155 const [excelExporting, setExcelExporting] = useState(false); 156 const [excelExporting, setExcelExporting] = useState(false);
@@ -272,7 +273,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -272,7 +273,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
272 }; 273 };
273 274
274 const toolbarSection = ( 275 const toolbarSection = (
275 - <div className="pb-4"> 276 + <div className={cn(!locationBulkEditPage && "pb-4", locationBulkEditPage && "hidden")}>
276 <div className="flex flex-col gap-4"> 277 <div className="flex flex-col gap-4">
277 <div className="flex flex-nowrap items-center gap-3"> 278 <div className="flex flex-nowrap items-center gap-3">
278 <Input 279 <Input
@@ -362,7 +363,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -362,7 +363,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
362 return; 363 return;
363 } 364 }
364 setBulkEditSeed(seed); 365 setBulkEditSeed(seed);
365 - setBulkEditOpen(true); 366 + setLocationBulkEditPage(true);
366 }} 367 }}
367 > 368 >
368 Bulk Edit 369 Bulk Edit
@@ -385,7 +386,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -385,7 +386,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
385 <Table> 386 <Table>
386 <TableHeader> 387 <TableHeader>
387 <TableRow className="bg-gray-100 hover:bg-gray-100"> 388 <TableRow className="bg-gray-100 hover:bg-gray-100">
388 - <TableHead className="text-gray-900 font-bold border-r w-12 shrink-0 text-center pl-2 pr-4"> 389 + <TableHead className="text-gray-900 font-bold border-r w-12 shrink-0 text-center px-3">
389 <Checkbox 390 <Checkbox
390 checked={locations.length > 0 && locations.every((l) => selectedIds.has(l.id))} 391 checked={locations.length > 0 && locations.every((l) => selectedIds.has(l.id))}
391 onCheckedChange={(c) => { 392 onCheckedChange={(c) => {
@@ -427,7 +428,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -427,7 +428,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
427 ) : ( 428 ) : (
428 locations.map((loc) => ( 429 locations.map((loc) => (
429 <TableRow key={loc.id}> 430 <TableRow key={loc.id}>
430 - <TableCell className="border-r w-12 shrink-0 text-center pl-2 pr-4"> 431 + <TableCell className="border-r w-12 shrink-0 text-center px-3">
431 <Checkbox 432 <Checkbox
432 checked={selectedIds.has(loc.id)} 433 checked={selectedIds.has(loc.id)}
433 onCheckedChange={(c) => { 434 onCheckedChange={(c) => {
@@ -567,6 +568,26 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -567,6 +568,26 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
567 </> 568 </>
568 ); 569 );
569 570
  571 + const listOrBulkMain = locationBulkEditPage ? (
  572 + <div className="flex-1 overflow-auto pt-6 min-h-0">
  573 + <div className="bg-white border border-gray-200 shadow-sm rounded-md overflow-hidden flex flex-col min-h-0 h-full max-h-full">
  574 + <LocationBulkEditPage
  575 + seed={bulkEditSeed}
  576 + onBack={() => {
  577 + setLocationBulkEditPage(false);
  578 + setBulkEditSeed([]);
  579 + }}
  580 + onSaved={() => {
  581 + setSelectedIds(new Set());
  582 + refreshList();
  583 + }}
  584 + />
  585 + </div>
  586 + </div>
  587 + ) : (
  588 + tableAndPagination
  589 + );
  590 +
570 const dialogs = ( 591 const dialogs = (
571 <> 592 <>
572 <CreateLocationDialog 593 <CreateLocationDialog
@@ -629,15 +650,6 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -629,15 +650,6 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
629 }} 650 }}
630 /> 651 />
631 652
632 - <LocationBulkEditDialog  
633 - open={bulkEditOpen}  
634 - onOpenChange={setBulkEditOpen}  
635 - seed={bulkEditSeed}  
636 - onSaved={() => {  
637 - setSelectedIds(new Set());  
638 - refreshList();  
639 - }}  
640 - />  
641 </> 653 </>
642 ); 654 );
643 655
@@ -645,7 +657,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -645,7 +657,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
645 return ( 657 return (
646 <div className="h-full flex flex-col min-h-0"> 658 <div className="h-full flex flex-col min-h-0">
647 {renderBeforeTabs(toolbarSection)} 659 {renderBeforeTabs(toolbarSection)}
648 - <div className="flex-1 min-h-0 flex flex-col overflow-hidden">{tableAndPagination}</div> 660 + <div className="flex-1 min-h-0 flex flex-col overflow-hidden">{listOrBulkMain}</div>
649 {dialogs} 661 {dialogs}
650 </div> 662 </div>
651 ); 663 );
@@ -654,7 +666,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) { @@ -654,7 +666,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
654 return ( 666 return (
655 <div className="h-full flex flex-col min-h-0"> 667 <div className="h-full flex flex-col min-h-0">
656 {toolbarSection} 668 {toolbarSection}
657 - <div className="flex-1 min-h-0 flex flex-col overflow-hidden">{tableAndPagination}</div> 669 + <div className="flex-1 min-h-0 flex flex-col overflow-hidden">{listOrBulkMain}</div>
658 {dialogs} 670 {dialogs}
659 </div> 671 </div>
660 ); 672 );
美国版/Food Labeling Management Platform/src/components/locations/location-bulk-edit-dialog.tsx renamed to 美国版/Food Labeling Management Platform/src/components/locations/location-bulk-edit-page.tsx
1 -import React, { useEffect, useMemo, useState } from "react"; 1 +import React, { useEffect, useState } from "react";
2 import { Button } from "../ui/button"; 2 import { Button } from "../ui/button";
3 import { Input } from "../ui/input"; 3 import { Input } from "../ui/input";
4 import { Switch } from "../ui/switch"; 4 import { Switch } from "../ui/switch";
5 -import {  
6 - Dialog,  
7 - DialogContent,  
8 - DialogDescription,  
9 - DialogHeader,  
10 - DialogTitle,  
11 -} from "../ui/dialog";  
12 import { toast } from "sonner"; 5 import { toast } from "sonner";
13 import { ApiError } from "../../lib/apiClient"; 6 import { ApiError } from "../../lib/apiClient";
14 import { updateLocationsBulk, type LocationBulkUpdateItemVo } from "../../services/locationService"; 7 import { updateLocationsBulk, type LocationBulkUpdateItemVo } from "../../services/locationService";
@@ -22,11 +15,9 @@ function isValidBulkId(id: string): boolean { @@ -22,11 +15,9 @@ function isValidBulkId(id: string): boolean {
22 return s.toLowerCase() !== ZERO; 15 return s.toLowerCase() !== ZERO;
23 } 16 }
24 17
25 -export type LocationBulkEditDialogProps = {  
26 - open: boolean;  
27 - onOpenChange: (open: boolean) => void;  
28 - /** 从列表勾选带入的行 */ 18 +export type LocationBulkEditPageProps = {
29 seed: LocationDto[]; 19 seed: LocationDto[];
  20 + onBack: () => void;
30 onSaved: () => void; 21 onSaved: () => void;
31 }; 22 };
32 23
@@ -52,38 +43,13 @@ function locToRow(loc: LocationDto): RowState { @@ -52,38 +43,13 @@ function locToRow(loc: LocationDto): RowState {
52 }; 43 };
53 } 44 }
54 45
55 -function emptyPadRow(): RowState {  
56 - return {  
57 - id: "",  
58 - locationCodeReadonly: "",  
59 - partner: "",  
60 - groupName: "",  
61 - locationName: "",  
62 - street: "",  
63 - city: "",  
64 - stateCode: "",  
65 - country: "",  
66 - zipCode: "",  
67 - phone: "",  
68 - email: "",  
69 - latitude: null,  
70 - longitude: null,  
71 - state: true,  
72 - };  
73 -}  
74 -  
75 -export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: LocationBulkEditDialogProps) { 46 +export function LocationBulkEditPage({ seed, onBack, onSaved }: LocationBulkEditPageProps) {
76 const [rows, setRows] = useState<RowState[]>([]); 47 const [rows, setRows] = useState<RowState[]>([]);
77 const [saving, setSaving] = useState(false); 48 const [saving, setSaving] = useState(false);
78 49
79 - const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]);  
80 -  
81 useEffect(() => { 50 useEffect(() => {
82 - if (!open) return;  
83 - const data = seed.map(locToRow);  
84 - const pad = Math.max(0, minRows - data.length);  
85 - setRows([...data, ...Array.from({ length: pad }, () => emptyPadRow())]);  
86 - }, [open, seed, minRows]); 51 + setRows(seed.map(locToRow));
  52 + }, [seed]);
87 53
88 const updateRow = (idx: number, patch: Partial<RowState>) => { 54 const updateRow = (idx: number, patch: Partial<RowState>) => {
89 setRows((prev) => { 55 setRows((prev) => {
@@ -93,13 +59,6 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo @@ -93,13 +59,6 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo
93 }); 59 });
94 }; 60 };
95 61
96 - const removeRow = (idx: number) => {  
97 - setRows((prev) => {  
98 - if (prev.length <= 1) return prev;  
99 - return prev.filter((_, i) => i !== idx);  
100 - });  
101 - };  
102 -  
103 const handleSave = async () => { 62 const handleSave = async () => {
104 const items: LocationBulkUpdateItemVo[] = rows 63 const items: LocationBulkUpdateItemVo[] = rows
105 .filter((r) => isValidBulkId(r.id)) 64 .filter((r) => isValidBulkId(r.id))
@@ -121,7 +80,7 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo @@ -121,7 +80,7 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo
121 })); 80 }));
122 81
123 if (items.length === 0) { 82 if (items.length === 0) {
124 - toast.error("No valid rows", { description: "Select locations in the list or keep rows with valid IDs." }); 83 + toast.error("No valid rows", { description: "Select locations in the list first, then open Bulk Edit." });
125 return; 84 return;
126 } 85 }
127 86
@@ -139,7 +98,7 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo @@ -139,7 +98,7 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo
139 toast.message("Errors (first 5)", { description: preview }); 98 toast.message("Errors (first 5)", { description: preview });
140 } 99 }
141 onSaved(); 100 onSaved();
142 - onOpenChange(false); 101 + onBack();
143 } catch (e) { 102 } catch (e) {
144 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed."; 103 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
145 toast.error("Bulk save failed", { description: msg }); 104 toast.error("Bulk save failed", { description: msg });
@@ -149,131 +108,133 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo @@ -149,131 +108,133 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo
149 }; 108 };
150 109
151 return ( 110 return (
152 - <Dialog open={open} onOpenChange={onOpenChange}>  
153 - <DialogContent className="max-w-[min(96vw,1200px)] w-full max-h-[90vh] flex flex-col gap-0 p-0">  
154 - <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">  
155 - <Button type="button" variant="outline" className="shrink-0" onClick={() => onOpenChange(false)}>  
156 - Back  
157 - </Button>  
158 - <DialogHeader className="flex-1 text-center space-y-0 py-0">  
159 - <DialogTitle className="text-base">Location bulk edit</DialogTitle>  
160 - <DialogDescription className="sr-only">Edit multiple locations and save all.</DialogDescription>  
161 - </DialogHeader>  
162 - <Button  
163 - type="button"  
164 - className="bg-green-600 hover:bg-green-700 text-white shrink-0"  
165 - disabled={saving}  
166 - onClick={() => void handleSave()}  
167 - >  
168 - {saving ? "Saving…" : "Save All"}  
169 - </Button>  
170 - </div>  
171 - <div className="overflow-auto flex-1 min-h-0 px-2 py-3">  
172 - <table className="w-full text-xs border-collapse border border-gray-200">  
173 - <thead className="bg-gray-100 sticky top-0 z-10">  
174 - <tr>  
175 - <th className="border p-1 w-8" />  
176 - <th className="border p-1 whitespace-nowrap">Location ID</th>  
177 - <th className="border p-1 whitespace-nowrap">Company</th>  
178 - <th className="border p-1 whitespace-nowrap">Region</th>  
179 - <th className="border p-1 whitespace-nowrap">Location Name *</th>  
180 - <th className="border p-1 whitespace-nowrap">Street</th>  
181 - <th className="border p-1 whitespace-nowrap">City</th>  
182 - <th className="border p-1 whitespace-nowrap">State</th>  
183 - <th className="border p-1 whitespace-nowrap">Country</th>  
184 - <th className="border p-1 whitespace-nowrap">Zip</th>  
185 - <th className="border p-1 whitespace-nowrap">Phone</th>  
186 - <th className="border p-1 whitespace-nowrap">Email</th>  
187 - <th className="border p-1 whitespace-nowrap">Lat</th>  
188 - <th className="border p-1 whitespace-nowrap">Lng</th>  
189 - <th className="border p-1 whitespace-nowrap">Active</th> 111 + <div className="flex flex-col h-full min-h-0 bg-white">
  112 + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">
  113 + <Button type="button" variant="outline" onClick={onBack}>
  114 + Back
  115 + </Button>
  116 + <h1 className="text-base font-semibold text-gray-900 flex-1 text-center truncate px-2">Location bulk edit</h1>
  117 + <Button
  118 + type="button"
  119 + className="bg-green-600 hover:bg-green-700 text-white shrink-0"
  120 + disabled={saving}
  121 + onClick={() => void handleSave()}
  122 + >
  123 + {saving ? "Saving…" : "Save All"}
  124 + </Button>
  125 + </div>
  126 + <div className="overflow-auto flex-1 min-h-0 px-2 py-3">
  127 + <table className="w-full text-xs border-collapse border border-gray-200">
  128 + <thead className="bg-gray-100 sticky top-0 z-10">
  129 + <tr>
  130 + <th className="border p-1 w-9 text-center text-gray-600 font-semibold">#</th>
  131 + <th className="border p-1 whitespace-nowrap">Location ID</th>
  132 + <th className="border p-1 whitespace-nowrap">Company</th>
  133 + <th className="border p-1 whitespace-nowrap">Region</th>
  134 + <th className="border p-1 whitespace-nowrap">Location Name *</th>
  135 + <th className="border p-1 whitespace-nowrap">Street</th>
  136 + <th className="border p-1 whitespace-nowrap">City</th>
  137 + <th className="border p-1 whitespace-nowrap">State</th>
  138 + <th className="border p-1 whitespace-nowrap">Country</th>
  139 + <th className="border p-1 whitespace-nowrap">Zip</th>
  140 + <th className="border p-1 whitespace-nowrap">Phone</th>
  141 + <th className="border p-1 whitespace-nowrap">Email</th>
  142 + <th className="border p-1 whitespace-nowrap">Lat</th>
  143 + <th className="border p-1 whitespace-nowrap">Lng</th>
  144 + <th className="border p-1 whitespace-nowrap">Active</th>
  145 + </tr>
  146 + </thead>
  147 + <tbody>
  148 + {rows.map((r, idx) => (
  149 + <tr key={`${r.id}-${idx}`} className="bg-white">
  150 + <td className="border p-1 text-center align-middle text-gray-700 tabular-nums text-xs font-medium">
  151 + {idx + 1}
  152 + </td>
  153 + <td className="border p-1 align-top">
  154 + <Input
  155 + className="h-7 text-xs min-w-[100px]"
  156 + value={r.locationCodeReadonly}
  157 + readOnly
  158 + title="Location ID is not changed in bulk edit"
  159 + />
  160 + </td>
  161 + <td className="border p-1 align-top">
  162 + <Input
  163 + className="h-7 text-xs min-w-[80px]"
  164 + value={r.partner ?? ""}
  165 + onChange={(e) => updateRow(idx, { partner: e.target.value })}
  166 + />
  167 + </td>
  168 + <td className="border p-1 align-top">
  169 + <Input
  170 + className="h-7 text-xs min-w-[80px]"
  171 + value={r.groupName ?? ""}
  172 + onChange={(e) => updateRow(idx, { groupName: e.target.value })}
  173 + />
  174 + </td>
  175 + <td className="border p-1 align-top">
  176 + <Input
  177 + className="h-7 text-xs min-w-[100px]"
  178 + value={r.locationName}
  179 + onChange={(e) => updateRow(idx, { locationName: e.target.value })}
  180 + />
  181 + </td>
  182 + <td className="border p-1 align-top">
  183 + <Input className="h-7 text-xs min-w-[80px]" value={r.street ?? ""} onChange={(e) => updateRow(idx, { street: e.target.value })} />
  184 + </td>
  185 + <td className="border p-1 align-top">
  186 + <Input className="h-7 text-xs min-w-[72px]" value={r.city ?? ""} onChange={(e) => updateRow(idx, { city: e.target.value })} />
  187 + </td>
  188 + <td className="border p-1 align-top">
  189 + <Input className="h-7 text-xs min-w-[48px]" value={r.stateCode ?? ""} onChange={(e) => updateRow(idx, { stateCode: e.target.value })} />
  190 + </td>
  191 + <td className="border p-1 align-top">
  192 + <Input className="h-7 text-xs min-w-[56px]" value={r.country ?? ""} onChange={(e) => updateRow(idx, { country: e.target.value })} />
  193 + </td>
  194 + <td className="border p-1 align-top">
  195 + <Input className="h-7 text-xs min-w-[56px]" value={r.zipCode ?? ""} onChange={(e) => updateRow(idx, { zipCode: e.target.value })} />
  196 + </td>
  197 + <td className="border p-1 align-top">
  198 + <Input className="h-7 text-xs min-w-[88px]" value={r.phone ?? ""} onChange={(e) => updateRow(idx, { phone: e.target.value })} />
  199 + </td>
  200 + <td className="border p-1 align-top">
  201 + <Input className="h-7 text-xs min-w-[120px]" value={r.email ?? ""} onChange={(e) => updateRow(idx, { email: e.target.value })} />
  202 + </td>
  203 + <td className="border p-1 align-top">
  204 + <Input
  205 + className="h-7 text-xs min-w-[64px]"
  206 + value={r.latitude === null || r.latitude === undefined ? "" : String(r.latitude)}
  207 + onChange={(e) => {
  208 + const v = e.target.value.trim();
  209 + updateRow(idx, { latitude: v === "" ? null : Number(v) });
  210 + }}
  211 + />
  212 + </td>
  213 + <td className="border p-1 align-top">
  214 + <Input
  215 + className="h-7 text-xs min-w-[64px]"
  216 + value={r.longitude === null || r.longitude === undefined ? "" : String(r.longitude)}
  217 + onChange={(e) => {
  218 + const v = e.target.value.trim();
  219 + updateRow(idx, { longitude: v === "" ? null : Number(v) });
  220 + }}
  221 + />
  222 + </td>
  223 + <td className="border p-1 align-middle text-center">
  224 + <div className="flex justify-center">
  225 + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />
  226 + </div>
  227 + </td>
190 </tr> 228 </tr>
191 - </thead>  
192 - <tbody>  
193 - {rows.map((r, idx) => (  
194 - <tr key={`${r.id || "new"}-${idx}`} className="bg-white">  
195 - <td className="border p-0 align-middle text-center">  
196 - <div className="flex flex-col items-center gap-1 py-1">  
197 - <span className="text-[10px] text-gray-500">{idx + 1}</span>  
198 - <Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-1" onClick={() => removeRow(idx)}>  
199 - ×  
200 - </Button>  
201 - </div>  
202 - </td>  
203 - <td className="border p-1 align-top">  
204 - <Input  
205 - className="h-7 text-xs min-w-[100px]"  
206 - value={r.locationCodeReadonly}  
207 - readOnly  
208 - title="Location ID is not changed in bulk edit"  
209 - />  
210 - </td>  
211 - <td className="border p-1 align-top">  
212 - <Input className="h-7 text-xs min-w-[80px]" value={r.partner ?? ""} onChange={(e) => updateRow(idx, { partner: e.target.value })} />  
213 - </td>  
214 - <td className="border p-1 align-top">  
215 - <Input className="h-7 text-xs min-w-[80px]" value={r.groupName ?? ""} onChange={(e) => updateRow(idx, { groupName: e.target.value })} />  
216 - </td>  
217 - <td className="border p-1 align-top">  
218 - <Input className="h-7 text-xs min-w-[100px]" value={r.locationName} onChange={(e) => updateRow(idx, { locationName: e.target.value })} />  
219 - </td>  
220 - <td className="border p-1 align-top">  
221 - <Input className="h-7 text-xs min-w-[80px]" value={r.street ?? ""} onChange={(e) => updateRow(idx, { street: e.target.value })} />  
222 - </td>  
223 - <td className="border p-1 align-top">  
224 - <Input className="h-7 text-xs min-w-[72px]" value={r.city ?? ""} onChange={(e) => updateRow(idx, { city: e.target.value })} />  
225 - </td>  
226 - <td className="border p-1 align-top">  
227 - <Input className="h-7 text-xs min-w-[48px]" value={r.stateCode ?? ""} onChange={(e) => updateRow(idx, { stateCode: e.target.value })} />  
228 - </td>  
229 - <td className="border p-1 align-top">  
230 - <Input className="h-7 text-xs min-w-[56px]" value={r.country ?? ""} onChange={(e) => updateRow(idx, { country: e.target.value })} />  
231 - </td>  
232 - <td className="border p-1 align-top">  
233 - <Input className="h-7 text-xs min-w-[56px]" value={r.zipCode ?? ""} onChange={(e) => updateRow(idx, { zipCode: e.target.value })} />  
234 - </td>  
235 - <td className="border p-1 align-top">  
236 - <Input className="h-7 text-xs min-w-[88px]" value={r.phone ?? ""} onChange={(e) => updateRow(idx, { phone: e.target.value })} />  
237 - </td>  
238 - <td className="border p-1 align-top">  
239 - <Input className="h-7 text-xs min-w-[120px]" value={r.email ?? ""} onChange={(e) => updateRow(idx, { email: e.target.value })} />  
240 - </td>  
241 - <td className="border p-1 align-top">  
242 - <Input  
243 - className="h-7 text-xs min-w-[64px]"  
244 - value={r.latitude === null || r.latitude === undefined ? "" : String(r.latitude)}  
245 - onChange={(e) => {  
246 - const v = e.target.value.trim();  
247 - updateRow(idx, { latitude: v === "" ? null : Number(v) });  
248 - }}  
249 - />  
250 - </td>  
251 - <td className="border p-1 align-top">  
252 - <Input  
253 - className="h-7 text-xs min-w-[64px]"  
254 - value={r.longitude === null || r.longitude === undefined ? "" : String(r.longitude)}  
255 - onChange={(e) => {  
256 - const v = e.target.value.trim();  
257 - updateRow(idx, { longitude: v === "" ? null : Number(v) });  
258 - }}  
259 - />  
260 - </td>  
261 - <td className="border p-1 align-middle text-center">  
262 - <div className="flex justify-center">  
263 - <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />  
264 - </div>  
265 - </td>  
266 - </tr>  
267 - ))}  
268 - </tbody>  
269 - </table>  
270 - </div>  
271 - <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">  
272 - <p>You can copy and paste from Excel or Google Sheets.</p>  
273 - <p>Columns marked * are required for rows that have a valid Location row id.</p>  
274 - <p>Use the × on each row to remove a row (keep at least one row).</p>  
275 - </div>  
276 - </DialogContent>  
277 - </Dialog> 229 + ))}
  230 + </tbody>
  231 + </table>
  232 + </div>
  233 + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">
  234 + <p>Only the locations you selected in the list are shown here.</p>
  235 + <p>You can copy and paste from Excel or Google Sheets.</p>
  236 + <p>Columns marked * are required for each row.</p>
  237 + </div>
  238 + </div>
278 ); 239 );
279 } 240 }
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
@@ -1096,7 +1096,7 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -1096,7 +1096,7 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
1096 <Table> 1096 <Table>
1097 <TableHeader> 1097 <TableHeader>
1098 <TableRow className="bg-gray-100"> 1098 <TableRow className="bg-gray-100">
1099 - <TableHead className="font-bold text-black border-r w-10 text-center"> 1099 + <TableHead className="font-bold text-black border-r w-12 shrink-0 text-center px-3">
1100 <Checkbox 1100 <Checkbox
1101 checked={members.length > 0 && members.every((m) => selectedMemberIds.has(m.id))} 1101 checked={members.length > 0 && members.every((m) => selectedMemberIds.has(m.id))}
1102 onCheckedChange={(c) => { 1102 onCheckedChange={(c) => {
@@ -1131,7 +1131,7 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie @@ -1131,7 +1131,7 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
1131 ) : ( 1131 ) : (
1132 members.map((m) => ( 1132 members.map((m) => (
1133 <TableRow key={m.id}> 1133 <TableRow key={m.id}>
1134 - <TableCell className="border-r w-10 text-center"> 1134 + <TableCell className="border-r w-12 shrink-0 text-center px-3">
1135 <Checkbox 1135 <Checkbox
1136 checked={selectedMemberIds.has(m.id)} 1136 checked={selectedMemberIds.has(m.id)}
1137 onCheckedChange={(c) => { 1137 onCheckedChange={(c) => {
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
@@ -81,7 +81,8 @@ import type { ProductCategoryDto, ProductCategoryCreateInput } from &quot;../../types @@ -81,7 +81,8 @@ import type { ProductCategoryDto, ProductCategoryCreateInput } from &quot;../../types
81 import { SearchableSelect } from "../ui/searchable-select"; 81 import { SearchableSelect } from "../ui/searchable-select";
82 import { SearchableMultiSelect } from "../ui/searchable-multi-select"; 82 import { SearchableMultiSelect } from "../ui/searchable-multi-select";
83 import { BatchImportDialog } from "../bulk/batch-import-dialog"; 83 import { BatchImportDialog } from "../bulk/batch-import-dialog";
84 -import { ProductBulkEditDialog } from "./product-bulk-edit-dialog"; 84 +import { ProductBulkEditPage } from "./product-bulk-edit-page";
  85 +import { cn } from "../ui/utils";
85 import { 86 import {
86 Pagination, 87 Pagination,
87 PaginationContent, 88 PaginationContent,
@@ -170,7 +171,7 @@ export function ProductsView() { @@ -170,7 +171,7 @@ export function ProductsView() {
170 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null); 171 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null);
171 const [selectedProductIds, setSelectedProductIds] = useState<Set<string>>(() => new Set()); 172 const [selectedProductIds, setSelectedProductIds] = useState<Set<string>>(() => new Set());
172 const [bulkImportOpen, setBulkImportOpen] = useState(false); 173 const [bulkImportOpen, setBulkImportOpen] = useState(false);
173 - const [bulkEditOpen, setBulkEditOpen] = useState(false); 174 + const [productBulkEditPage, setProductBulkEditPage] = useState(false);
174 const [bulkEditSeed, setBulkEditSeed] = useState<ProductDto[]>([]); 175 const [bulkEditSeed, setBulkEditSeed] = useState<ProductDto[]>([]);
175 const [tmplDownloading, setTmplDownloading] = useState(false); 176 const [tmplDownloading, setTmplDownloading] = useState(false);
176 const [excelExporting, setExcelExporting] = useState(false); 177 const [excelExporting, setExcelExporting] = useState(false);
@@ -362,6 +363,10 @@ export function ProductsView() { @@ -362,6 +363,10 @@ export function ProductsView() {
362 reloadCategoryCatalog(); 363 reloadCategoryCatalog();
363 }; 364 };
364 365
  366 + useEffect(() => {
  367 + if (activeTab !== "products") setProductBulkEditPage(false);
  368 + }, [activeTab]);
  369 +
365 const locationOptions = useMemo( 370 const locationOptions = useMemo(
366 () => 371 () =>
367 locations.map((loc) => ({ 372 locations.map((loc) => ({
@@ -394,7 +399,7 @@ export function ProductsView() { @@ -394,7 +399,7 @@ export function ProductsView() {
394 399
395 return ( 400 return (
396 <div className="h-full flex flex-col"> 401 <div className="h-full flex flex-col">
397 - <div className="pb-4"> 402 + <div className={cn("pb-4", productBulkEditPage && "hidden")}>
398 <div className="flex flex-col gap-3"> 403 <div className="flex flex-col gap-3">
399 <div className="flex flex-wrap items-center gap-3"> 404 <div className="flex flex-wrap items-center gap-3">
400 <div 405 <div
@@ -518,6 +523,23 @@ export function ProductsView() { @@ -518,6 +523,23 @@ export function ProductsView() {
518 </div> 523 </div>
519 524
520 <div className="flex-1 overflow-auto pt-6 flex flex-col min-h-0"> 525 <div className="flex-1 overflow-auto pt-6 flex flex-col min-h-0">
  526 + {productBulkEditPage ? (
  527 + <div className="bg-white border border-gray-200 shadow-sm rounded-md flex-1 flex flex-col min-h-0 overflow-hidden max-h-full">
  528 + <ProductBulkEditPage
  529 + seed={bulkEditSeed}
  530 + categories={productCategoriesCatalog}
  531 + onBack={() => {
  532 + setProductBulkEditPage(false);
  533 + setBulkEditSeed([]);
  534 + }}
  535 + onSaved={() => {
  536 + setSelectedProductIds(new Set());
  537 + refresh();
  538 + }}
  539 + />
  540 + </div>
  541 + ) : (
  542 + <>
521 <div className="flex flex-nowrap items-center justify-end gap-3 min-w-0 overflow-x-auto pb-0.5 mb-3 shrink-0 [scrollbar-width:thin]"> 543 <div className="flex flex-nowrap items-center justify-end gap-3 min-w-0 overflow-x-auto pb-0.5 mb-3 shrink-0 [scrollbar-width:thin]">
522 {activeTab === "products" && ( 544 {activeTab === "products" && (
523 <> 545 <>
@@ -568,7 +590,7 @@ export function ProductsView() { @@ -568,7 +590,7 @@ export function ProductsView() {
568 return; 590 return;
569 } 591 }
570 setBulkEditSeed(seed); 592 setBulkEditSeed(seed);
571 - setBulkEditOpen(true); 593 + setProductBulkEditPage(true);
572 }} 594 }}
573 > 595 >
574 <Edit className="w-4 h-4" /> Bulk Edit 596 <Edit className="w-4 h-4" /> Bulk Edit
@@ -602,7 +624,7 @@ export function ProductsView() { @@ -602,7 +624,7 @@ export function ProductsView() {
602 <Table> 624 <Table>
603 <TableHeader> 625 <TableHeader>
604 <TableRow className="bg-gray-100 hover:bg-gray-100"> 626 <TableRow className="bg-gray-100 hover:bg-gray-100">
605 - <TableHead className="text-gray-900 font-bold border-r w-10 text-center whitespace-nowrap"> 627 + <TableHead className="text-gray-900 font-bold border-r w-12 shrink-0 text-center whitespace-nowrap px-3">
606 <Checkbox 628 <Checkbox
607 checked={products.length > 0 && products.every((p) => selectedProductIds.has(p.id))} 629 checked={products.length > 0 && products.every((p) => selectedProductIds.has(p.id))}
608 onCheckedChange={(c) => { 630 onCheckedChange={(c) => {
@@ -644,7 +666,7 @@ export function ProductsView() { @@ -644,7 +666,7 @@ export function ProductsView() {
644 const active = p.state !== false; 666 const active = p.state !== false;
645 return ( 667 return (
646 <TableRow key={p.id}> 668 <TableRow key={p.id}>
647 - <TableCell className="border-r w-10 text-center"> 669 + <TableCell className="border-r w-12 shrink-0 text-center px-3">
648 <Checkbox 670 <Checkbox
649 checked={selectedProductIds.has(p.id)} 671 checked={selectedProductIds.has(p.id)}
650 onCheckedChange={(c) => { 672 onCheckedChange={(c) => {
@@ -995,6 +1017,8 @@ export function ProductsView() { @@ -995,6 +1017,8 @@ export function ProductsView() {
995 </div> 1017 </div>
996 </div> 1018 </div>
997 )} 1019 )}
  1020 + </>
  1021 + )}
998 </div> 1022 </div>
999 1023
1000 <ProductFormDialog 1024 <ProductFormDialog
@@ -1049,17 +1073,6 @@ export function ProductsView() { @@ -1049,17 +1073,6 @@ export function ProductsView() {
1049 }} 1073 }}
1050 /> 1074 />
1051 1075
1052 - <ProductBulkEditDialog  
1053 - open={bulkEditOpen}  
1054 - onOpenChange={setBulkEditOpen}  
1055 - seed={bulkEditSeed}  
1056 - categories={productCategoriesCatalog}  
1057 - onSaved={() => {  
1058 - setSelectedProductIds(new Set());  
1059 - refresh();  
1060 - }}  
1061 - />  
1062 -  
1063 <ProductCategoryFormDialog 1076 <ProductCategoryFormDialog
1064 open={isProductCategoryDialogOpen} 1077 open={isProductCategoryDialogOpen}
1065 category={editingProductCategory} 1078 category={editingProductCategory}
美国版/Food Labeling Management Platform/src/components/products/product-bulk-edit-dialog.tsx renamed to 美国版/Food Labeling Management Platform/src/components/products/product-bulk-edit-page.tsx
1 -import React, { useEffect, useMemo, useState } from "react"; 1 +import React, { useEffect, useState } from "react";
2 import { Button } from "../ui/button"; 2 import { Button } from "../ui/button";
3 import { Input } from "../ui/input"; 3 import { Input } from "../ui/input";
4 import { Switch } from "../ui/switch"; 4 import { Switch } from "../ui/switch";
5 import { 5 import {
6 - Dialog,  
7 - DialogContent,  
8 - DialogDescription,  
9 - DialogHeader,  
10 - DialogTitle,  
11 -} from "../ui/dialog";  
12 -import {  
13 Select, 6 Select,
14 SelectContent, 7 SelectContent,
15 SelectItem, 8 SelectItem,
@@ -26,11 +19,10 @@ function isValidBulkProductId(id: string): boolean { @@ -26,11 +19,10 @@ function isValidBulkProductId(id: string): boolean {
26 return !!(id ?? "").trim(); 19 return !!(id ?? "").trim();
27 } 20 }
28 21
29 -export type ProductBulkEditDialogProps = {  
30 - open: boolean;  
31 - onOpenChange: (open: boolean) => void; 22 +export type ProductBulkEditPageProps = {
32 seed: ProductDto[]; 23 seed: ProductDto[];
33 categories: ProductCategoryDto[]; 24 categories: ProductCategoryDto[];
  25 + onBack: () => void;
34 onSaved: () => void; 26 onSaved: () => void;
35 }; 27 };
36 28
@@ -48,18 +40,6 @@ function productToRow(p: ProductDto, locationIds: string[]): RowState { @@ -48,18 +40,6 @@ function productToRow(p: ProductDto, locationIds: string[]): RowState {
48 }; 40 };
49 } 41 }
50 42
51 -function emptyPadRow(): RowState {  
52 - return {  
53 - id: "",  
54 - productCode: "",  
55 - productName: "",  
56 - categoryId: null,  
57 - productImageUrl: null,  
58 - state: true,  
59 - locationIds: [],  
60 - };  
61 -}  
62 -  
63 function parseIdsCsv(s: string): string[] { 43 function parseIdsCsv(s: string): string[] {
64 return s 44 return s
65 .split(/[,;|\s]+/) 45 .split(/[,;|\s]+/)
@@ -67,23 +47,16 @@ function parseIdsCsv(s: string): string[] { @@ -67,23 +47,16 @@ function parseIdsCsv(s: string): string[] {
67 .filter(Boolean); 47 .filter(Boolean);
68 } 48 }
69 49
70 -export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, onSaved }: ProductBulkEditDialogProps) { 50 +export function ProductBulkEditPage({ seed, categories, onBack, onSaved }: ProductBulkEditPageProps) {
71 const [rows, setRows] = useState<RowState[]>([]); 51 const [rows, setRows] = useState<RowState[]>([]);
72 const [saving, setSaving] = useState(false); 52 const [saving, setSaving] = useState(false);
73 const [locCsvByIdx, setLocCsvByIdx] = useState<string[]>([]); 53 const [locCsvByIdx, setLocCsvByIdx] = useState<string[]>([]);
74 54
75 - const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]);  
76 -  
77 useEffect(() => { 55 useEffect(() => {
78 - if (!open) return;  
79 const data = seed.map((p) => productToRow(p, p.locationIds ?? [])); 56 const data = seed.map((p) => productToRow(p, p.locationIds ?? []));
80 - const pad = Math.max(0, minRows - data.length);  
81 - const padded = [...data, ...Array.from({ length: pad }, () => emptyPadRow())];  
82 - setRows(padded);  
83 - setLocCsvByIdx(  
84 - padded.map((r) => (r.locationIds && r.locationIds.length ? r.locationIds.join(",") : "")),  
85 - );  
86 - }, [open, seed, minRows]); 57 + setRows(data);
  58 + setLocCsvByIdx(data.map((r) => (r.locationIds && r.locationIds.length ? r.locationIds.join(",") : "")));
  59 + }, [seed]);
87 60
88 const updateRow = (idx: number, patch: Partial<RowState>) => { 61 const updateRow = (idx: number, patch: Partial<RowState>) => {
89 setRows((prev) => { 62 setRows((prev) => {
@@ -93,11 +66,6 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on @@ -93,11 +66,6 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on
93 }); 66 });
94 }; 67 };
95 68
96 - const removeRow = (idx: number) => {  
97 - setRows((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx)));  
98 - setLocCsvByIdx((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx)));  
99 - };  
100 -  
101 const syncLocIds = (idx: number, csv: string) => { 69 const syncLocIds = (idx: number, csv: string) => {
102 const nextCsv = [...locCsvByIdx]; 70 const nextCsv = [...locCsvByIdx];
103 nextCsv[idx] = csv; 71 nextCsv[idx] = csv;
@@ -119,7 +87,7 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on @@ -119,7 +87,7 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on
119 })); 87 }));
120 88
121 if (items.length === 0) { 89 if (items.length === 0) {
122 - toast.error("No valid rows", { description: "Select products in the list first." }); 90 + toast.error("No valid rows", { description: "Select products in the list first, then open Bulk Edit." });
123 return; 91 return;
124 } 92 }
125 93
@@ -130,7 +98,7 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on @@ -130,7 +98,7 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on
130 description: `Success: ${res.successCount}, failed: ${res.failCount}`, 98 description: `Success: ${res.successCount}, failed: ${res.failCount}`,
131 }); 99 });
132 onSaved(); 100 onSaved();
133 - onOpenChange(false); 101 + onBack();
134 } catch (e) { 102 } catch (e) {
135 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed."; 103 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
136 toast.error("Bulk save failed", { description: msg }); 104 toast.error("Bulk save failed", { description: msg });
@@ -140,96 +108,89 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on @@ -140,96 +108,89 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on
140 }; 108 };
141 109
142 return ( 110 return (
143 - <Dialog open={open} onOpenChange={onOpenChange}>  
144 - <DialogContent className="max-w-[min(96vw,1100px)] w-full max-h-[90vh] flex flex-col gap-0 p-0">  
145 - <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">  
146 - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>  
147 - Back  
148 - </Button>  
149 - <DialogHeader className="flex-1 text-center space-y-0 py-0">  
150 - <DialogTitle className="text-base">Product bulk edit</DialogTitle>  
151 - <DialogDescription className="sr-only">Edit products in a grid and save all.</DialogDescription>  
152 - </DialogHeader>  
153 - <Button type="button" className="bg-green-600 hover:bg-green-700 text-white shrink-0" disabled={saving} onClick={() => void handleSave()}>  
154 - {saving ? "Saving…" : "Save All"}  
155 - </Button>  
156 - </div>  
157 - <div className="overflow-auto flex-1 min-h-0 px-2 py-3">  
158 - <table className="w-full text-xs border-collapse border border-gray-200">  
159 - <thead className="bg-gray-100 sticky top-0 z-10">  
160 - <tr>  
161 - <th className="border p-1 w-8" />  
162 - <th className="border p-1 whitespace-nowrap">Product Code</th>  
163 - <th className="border p-1 whitespace-nowrap">Product *</th>  
164 - <th className="border p-1 whitespace-nowrap">Category</th>  
165 - <th className="border p-1 whitespace-nowrap">Image URL</th>  
166 - <th className="border p-1 whitespace-nowrap">Location IDs</th>  
167 - <th className="border p-1 whitespace-nowrap">Active</th> 111 + <div className="flex flex-col h-full min-h-0 bg-white">
  112 + <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 shrink-0">
  113 + <Button type="button" variant="outline" onClick={onBack}>
  114 + Back
  115 + </Button>
  116 + <h1 className="text-base font-semibold text-gray-900 flex-1 text-center truncate px-2">Product bulk edit</h1>
  117 + <Button
  118 + type="button"
  119 + className="bg-green-600 hover:bg-green-700 text-white shrink-0"
  120 + disabled={saving}
  121 + onClick={() => void handleSave()}
  122 + >
  123 + {saving ? "Saving…" : "Save All"}
  124 + </Button>
  125 + </div>
  126 + <div className="overflow-auto flex-1 min-h-0 px-2 py-3">
  127 + <table className="w-full text-xs border-collapse border border-gray-200">
  128 + <thead className="bg-gray-100 sticky top-0 z-10">
  129 + <tr>
  130 + <th className="border p-1 w-9 text-center text-gray-600 font-semibold">#</th>
  131 + <th className="border p-1 whitespace-nowrap">Product Code</th>
  132 + <th className="border p-1 whitespace-nowrap">Product *</th>
  133 + <th className="border p-1 whitespace-nowrap">Category</th>
  134 + <th className="border p-1 whitespace-nowrap">Image URL</th>
  135 + <th className="border p-1 whitespace-nowrap">Location IDs</th>
  136 + <th className="border p-1 whitespace-nowrap">Active</th>
  137 + </tr>
  138 + </thead>
  139 + <tbody>
  140 + {rows.map((r, idx) => (
  141 + <tr key={`${r.id}-${idx}`}>
  142 + <td className="border p-1 text-center align-middle text-gray-700 tabular-nums text-xs font-medium">
  143 + {idx + 1}
  144 + </td>
  145 + <td className="border p-1 align-top">
  146 + <Input className="h-7 text-xs" value={r.productCode ?? ""} onChange={(e) => updateRow(idx, { productCode: e.target.value })} />
  147 + </td>
  148 + <td className="border p-1 align-top">
  149 + <Input className="h-7 text-xs min-w-[120px]" value={r.productName} onChange={(e) => updateRow(idx, { productName: e.target.value })} />
  150 + </td>
  151 + <td className="border p-1 align-top min-w-[140px]">
  152 + <Select value={r.categoryId ?? "__none__"} onValueChange={(v) => updateRow(idx, { categoryId: v === "__none__" ? null : v })}>
  153 + <SelectTrigger className="h-7 text-xs">
  154 + <SelectValue placeholder="Category" />
  155 + </SelectTrigger>
  156 + <SelectContent>
  157 + <SelectItem value="__none__">(none)</SelectItem>
  158 + {categories.map((c) => (
  159 + <SelectItem key={c.id} value={c.id}>
  160 + {c.categoryName ?? c.id}
  161 + </SelectItem>
  162 + ))}
  163 + </SelectContent>
  164 + </Select>
  165 + </td>
  166 + <td className="border p-1 align-top">
  167 + <Input
  168 + className="h-7 text-xs min-w-[140px]"
  169 + value={r.productImageUrl ?? ""}
  170 + onChange={(e) => updateRow(idx, { productImageUrl: e.target.value || null })}
  171 + />
  172 + </td>
  173 + <td className="border p-1 align-top">
  174 + <Input
  175 + className="h-7 text-xs min-w-[160px]"
  176 + value={locCsvByIdx[idx] ?? ""}
  177 + onChange={(e) => syncLocIds(idx, e.target.value)}
  178 + placeholder="id1,id2"
  179 + />
  180 + </td>
  181 + <td className="border p-1 text-center align-middle">
  182 + <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />
  183 + </td>
168 </tr> 184 </tr>
169 - </thead>  
170 - <tbody>  
171 - {rows.map((r, idx) => (  
172 - <tr key={`${r.id || "e"}-${idx}`}>  
173 - <td className="border p-0 text-center align-top">  
174 - <div className="flex flex-col items-center gap-1 py-1">  
175 - <span className="text-[10px] text-gray-500">{idx + 1}</span>  
176 - <Button type="button" variant="ghost" size="sm" className="h-6 text-[10px] px-1" onClick={() => removeRow(idx)}>  
177 - ×  
178 - </Button>  
179 - </div>  
180 - </td>  
181 - <td className="border p-1 align-top">  
182 - <Input className="h-7 text-xs" value={r.productCode ?? ""} onChange={(e) => updateRow(idx, { productCode: e.target.value })} />  
183 - </td>  
184 - <td className="border p-1 align-top">  
185 - <Input className="h-7 text-xs min-w-[120px]" value={r.productName} onChange={(e) => updateRow(idx, { productName: e.target.value })} />  
186 - </td>  
187 - <td className="border p-1 align-top min-w-[140px]">  
188 - <Select  
189 - value={r.categoryId ?? "__none__"}  
190 - onValueChange={(v) => updateRow(idx, { categoryId: v === "__none__" ? null : v })}  
191 - >  
192 - <SelectTrigger className="h-7 text-xs">  
193 - <SelectValue placeholder="Category" />  
194 - </SelectTrigger>  
195 - <SelectContent>  
196 - <SelectItem value="__none__">(none)</SelectItem>  
197 - {categories.map((c) => (  
198 - <SelectItem key={c.id} value={c.id}>  
199 - {c.categoryName ?? c.id}  
200 - </SelectItem>  
201 - ))}  
202 - </SelectContent>  
203 - </Select>  
204 - </td>  
205 - <td className="border p-1 align-top">  
206 - <Input  
207 - className="h-7 text-xs min-w-[140px]"  
208 - value={r.productImageUrl ?? ""}  
209 - onChange={(e) => updateRow(idx, { productImageUrl: e.target.value || null })}  
210 - />  
211 - </td>  
212 - <td className="border p-1 align-top">  
213 - <Input  
214 - className="h-7 text-xs min-w-[160px]"  
215 - value={locCsvByIdx[idx] ?? ""}  
216 - onChange={(e) => syncLocIds(idx, e.target.value)}  
217 - placeholder="id1,id2"  
218 - />  
219 - </td>  
220 - <td className="border p-1 text-center align-middle">  
221 - <Switch checked={r.state !== false} onCheckedChange={(c) => updateRow(idx, { state: !!c })} />  
222 - </td>  
223 - </tr>  
224 - ))}  
225 - </tbody>  
226 - </table>  
227 - </div>  
228 - <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">  
229 - <p>Columns with * are required for saved rows.</p>  
230 - <p>Location IDs: comma-separated location primary keys (GUID).</p>  
231 - </div>  
232 - </DialogContent>  
233 - </Dialog> 185 + ))}
  186 + </tbody>
  187 + </table>
  188 + </div>
  189 + <div className="px-4 py-3 border-t border-gray-100 text-center text-xs text-gray-500 shrink-0 space-y-1">
  190 + <p>Only the products you selected in the list are shown here.</p>
  191 + <p>Columns with * are required for saved rows.</p>
  192 + <p>Location IDs: comma-separated location primary keys (GUID).</p>
  193 + </div>
  194 + </div>
234 ); 195 );
235 } 196 }
美国版/Food Labeling Management Platform/src/components/ui/table.tsx
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps&lt;&quot;th&quot;&gt;) { @@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps&lt;&quot;th&quot;&gt;) {
70 <th 70 <th
71 data-slot="table-head" 71 data-slot="table-head"
72 className={cn( 72 className={cn(
73 - "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", 73 + "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&>[role=checkbox]]:translate-y-[2px]",
74 className, 74 className,
75 )} 75 )}
76 {...props} 76 {...props}
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps&lt;&quot;td&quot;&gt;) { @@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps&lt;&quot;td&quot;&gt;) {
83 <td 83 <td
84 data-slot="table-cell" 84 data-slot="table-cell"
85 className={cn( 85 className={cn(
86 - "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", 86 + "p-2 align-middle whitespace-nowrap [&>[role=checkbox]]:translate-y-[2px]",
87 className, 87 className,
88 )} 88 )}
89 {...props} 89 {...props}
美国版/Food Labeling Management Platform/src/lib/batchFileHttp.ts
@@ -40,8 +40,11 @@ function parseFileNameFromContentDisposition(h: string | null): string | null { @@ -40,8 +40,11 @@ function parseFileNameFromContentDisposition(h: string | null): string | null {
40 40
41 function getAbpErrorMessage(payload: unknown): string | null { 41 function getAbpErrorMessage(payload: unknown): string | null {
42 if (!payload || typeof payload !== "object") return null; 42 if (!payload || typeof payload !== "object") return null;
43 - const p = payload as { error?: { message?: string } };  
44 - return p.error?.message?.trim() || null; 43 + const p = payload as { error?: { message?: string }; errors?: unknown };
  44 + const nested = p.error?.message?.trim();
  45 + if (nested) return nested;
  46 + if (typeof p.errors === "string" && p.errors.trim()) return p.errors.trim();
  47 + return null;
45 } 48 }
46 49
47 function unwrapEnvelope<T>(payload: unknown): T { 50 function unwrapEnvelope<T>(payload: unknown): T {
@@ -105,6 +108,51 @@ export async function authorizedGetBlobDownload(opts: { @@ -105,6 +108,51 @@ export async function authorizedGetBlobDownload(opts: {
105 } 108 }
106 109
107 /** 110 /**
  111 + * POST 下载二进制。ABP 约定控制器里 `Export*` / `Download*` 多为 **POST**;
  112 + * 若误用 GET,会命中 `GET …/{id}` 把路径段当成 id(如产品导出报「产品不存在」)。
  113 + */
  114 +export async function authorizedPostBlobDownload(opts: {
  115 + path: string;
  116 + query?: Record<string, unknown>;
  117 + defaultFileName: string;
  118 + signal?: AbortSignal;
  119 +}): Promise<void> {
  120 + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001";
  121 + const url = joinUrl(baseUrl, `${API_PREFIX}${opts.path}${toQueryString(opts.query ?? {})}`);
  122 + const token = getTokenForFetch();
  123 + const headers: Record<string, string> = { Accept: "*/*" };
  124 + if (token) headers.Authorization = `Bearer ${token}`;
  125 + const res = await fetch(url, {
  126 + method: "POST",
  127 + headers,
  128 + signal: opts.signal,
  129 + });
  130 + const ct = res.headers.get("content-type") ?? "";
  131 + if (!res.ok) {
  132 + if (ct.includes("application/json")) {
  133 + const payload = await res.json().catch(() => null);
  134 + const msg = getAbpErrorMessage(payload) || "Download failed.";
  135 + throw new ApiError(msg, res.status, payload);
  136 + }
  137 + const t = await res.text().catch(() => "");
  138 + throw new ApiError(t || "Download failed.", res.status, t);
  139 + }
  140 + if (ct.includes("application/json")) {
  141 + const payload = await res.json().catch(() => null);
  142 + const msg = getAbpErrorMessage(payload) || "Download failed.";
  143 + throw new ApiError(msg, res.status, payload);
  144 + }
  145 + const blob = await res.blob();
  146 + const name = parseFileNameFromContentDisposition(res.headers.get("content-disposition")) || opts.defaultFileName;
  147 + const href = URL.createObjectURL(blob);
  148 + const a = document.createElement("a");
  149 + a.href = href;
  150 + a.download = name;
  151 + a.click();
  152 + URL.revokeObjectURL(href);
  153 +}
  154 +
  155 +/**
108 * multipart 上传文件,解析 JSON 响应(兼容 ABP 包裹 `{ data, succeeded }`)。 156 * multipart 上传文件,解析 JSON 响应(兼容 ABP 包裹 `{ data, succeeded }`)。
109 */ 157 */
110 export async function authorizedPostMultipartJson<T>(opts: { 158 export async function authorizedPostMultipartJson<T>(opts: {
美国版/Food Labeling Management Platform/src/services/groupService.ts
@@ -114,7 +114,7 @@ export type GroupExportQuery = { @@ -114,7 +114,7 @@ export type GroupExportQuery = {
114 sorting?: string; 114 sorting?: string;
115 }; 115 };
116 116
117 -/** GET /api/app/group/export-pdf */ 117 +/** POST /api/app/group/export-pdf — ABP `Export*` 为 POST */
118 export async function exportGroupsPdf(input: GroupExportQuery, signal?: AbortSignal): Promise<Blob> { 118 export async function exportGroupsPdf(input: GroupExportQuery, signal?: AbortSignal): Promise<Blob> {
119 const baseUrl = getBaseUrl(); 119 const baseUrl = getBaseUrl();
120 const token = getToken(); 120 const token = getToken();
@@ -129,7 +129,7 @@ export async function exportGroupsPdf(input: GroupExportQuery, signal?: AbortSig @@ -129,7 +129,7 @@ export async function exportGroupsPdf(input: GroupExportQuery, signal?: AbortSig
129 ); 129 );
130 const headers: Record<string, string> = {}; 130 const headers: Record<string, string> = {};
131 if (token) headers.Authorization = `Bearer ${token}`; 131 if (token) headers.Authorization = `Bearer ${token}`;
132 - const res = await fetch(url, { method: "GET", headers, signal }); 132 + const res = await fetch(url, { method: "POST", headers, signal });
133 if (!res.ok) { 133 if (!res.ok) {
134 const ct = res.headers.get("content-type") ?? ""; 134 const ct = res.headers.get("content-type") ?? "";
135 let msg = "Export failed."; 135 let msg = "Export failed.";
美国版/Food Labeling Management Platform/src/services/locationService.ts
1 import { createApiClient } from "../lib/apiClient"; 1 import { createApiClient } from "../lib/apiClient";
2 -import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; 2 +import { authorizedPostBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
3 import type { 3 import type {
4 LocationCreateInput, 4 LocationCreateInput,
5 LocationDto, 5 LocationDto,
@@ -151,7 +151,7 @@ export type LocationBulkUpdateResultDto = { @@ -151,7 +151,7 @@ export type LocationBulkUpdateResultDto = {
151 }; 151 };
152 152
153 export async function downloadLocationImportTemplate(signal?: AbortSignal): Promise<void> { 153 export async function downloadLocationImportTemplate(signal?: AbortSignal): Promise<void> {
154 - await authorizedGetBlobDownload({ 154 + await authorizedPostBlobDownload({
155 path: "/location/download-location-import-template", 155 path: "/location/download-location-import-template",
156 defaultFileName: "Location-Manager-template.xlsx", 156 defaultFileName: "Location-Manager-template.xlsx",
157 signal, 157 signal,
@@ -159,7 +159,7 @@ export async function downloadLocationImportTemplate(signal?: AbortSignal): Prom @@ -159,7 +159,7 @@ export async function downloadLocationImportTemplate(signal?: AbortSignal): Prom
159 } 159 }
160 160
161 export async function exportLocationsExcel(input: LocationExportQueryInput, signal?: AbortSignal): Promise<void> { 161 export async function exportLocationsExcel(input: LocationExportQueryInput, signal?: AbortSignal): Promise<void> {
162 - await authorizedGetBlobDownload({ 162 + await authorizedPostBlobDownload({
163 path: "/location/export-locations-excel", 163 path: "/location/export-locations-excel",
164 query: { 164 query: {
165 Sorting: input.sorting, 165 Sorting: input.sorting,
@@ -185,9 +185,9 @@ export async function importLocationsBatch(file: File, signal?: AbortSignal): Pr @@ -185,9 +185,9 @@ export async function importLocationsBatch(file: File, signal?: AbortSignal): Pr
185 export async function updateLocationsBulk( 185 export async function updateLocationsBulk(
186 body: { items: LocationBulkUpdateItemVo[] }, 186 body: { items: LocationBulkUpdateItemVo[] },
187 ): Promise<LocationBulkUpdateResultDto> { 187 ): Promise<LocationBulkUpdateResultDto> {
188 - // ABP 约定控制器:`Update*Async` 多为 PUT,POST 会 405 188 + // ABP:`UpdateLocationsBulkAsync` → `locations-bulk`(勿写 `update-locations-bulk`)
189 return api.requestJson<LocationBulkUpdateResultDto>({ 189 return api.requestJson<LocationBulkUpdateResultDto>({
190 - path: "/location/update-locations-bulk", 190 + path: "/location/locations-bulk",
191 method: "PUT", 191 method: "PUT",
192 body, 192 body,
193 }); 193 });
美国版/Food Labeling Management Platform/src/services/partnerService.ts
@@ -118,7 +118,7 @@ export type PartnerExportQuery = { @@ -118,7 +118,7 @@ export type PartnerExportQuery = {
118 sorting?: string; 118 sorting?: string;
119 }; 119 };
120 120
121 -/** GET /api/app/partner/export-pdf — 与列表筛选字段一致 */ 121 +/** POST /api/app/partner/export-pdf — ABP `Export*` 为 POST,勿用 GET(会误命中 `GET …/{id}`) */
122 export async function exportPartnersPdf(input: PartnerExportQuery, signal?: AbortSignal): Promise<Blob> { 122 export async function exportPartnersPdf(input: PartnerExportQuery, signal?: AbortSignal): Promise<Blob> {
123 const baseUrl = getBaseUrl(); 123 const baseUrl = getBaseUrl();
124 const token = getToken(); 124 const token = getToken();
@@ -132,7 +132,7 @@ export async function exportPartnersPdf(input: PartnerExportQuery, signal?: Abor @@ -132,7 +132,7 @@ export async function exportPartnersPdf(input: PartnerExportQuery, signal?: Abor
132 ); 132 );
133 const headers: Record<string, string> = {}; 133 const headers: Record<string, string> = {};
134 if (token) headers.Authorization = `Bearer ${token}`; 134 if (token) headers.Authorization = `Bearer ${token}`;
135 - const res = await fetch(url, { method: "GET", headers, signal }); 135 + const res = await fetch(url, { method: "POST", headers, signal });
136 if (!res.ok) { 136 if (!res.ok) {
137 const ct = res.headers.get("content-type") ?? ""; 137 const ct = res.headers.get("content-type") ?? "";
138 let msg = "Export failed."; 138 let msg = "Export failed.";
美国版/Food Labeling Management Platform/src/services/productService.ts
1 import { createApiClient } from "../lib/apiClient"; 1 import { createApiClient } from "../lib/apiClient";
2 -import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; 2 +import { authorizedGetBlobDownload, authorizedPostBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
3 import type { 3 import type {
4 ProductCreateInput, 4 ProductCreateInput,
5 ProductDto, 5 ProductDto,
@@ -147,13 +147,17 @@ export type ProductBulkUpdateResultDto = { @@ -147,13 +147,17 @@ export type ProductBulkUpdateResultDto = {
147 }; 147 };
148 148
149 export async function downloadProductImportTemplate(signal?: AbortSignal): Promise<void> { 149 export async function downloadProductImportTemplate(signal?: AbortSignal): Promise<void> {
150 - await authorizedGetBlobDownload({ 150 + await authorizedPostBlobDownload({
151 path: `${PATH}/download-product-import-template`, 151 path: `${PATH}/download-product-import-template`,
152 defaultFileName: "Product-Manager-template.xlsx", 152 defaultFileName: "Product-Manager-template.xlsx",
153 signal, 153 signal,
154 }); 154 });
155 } 155 }
156 156
  157 +/**
  158 + * 与后端 `ExportProductsExcelAsync([FromQuery] ProductGetListInputVo)` 一致:GET + 查询参数。
  159 + * 勿用 POST,否则易与 `GET /product/{id}` 等路由混淆或 405。
  160 + */
157 export async function exportProductsExcel(input: ProductExportQueryInput, signal?: AbortSignal): Promise<void> { 161 export async function exportProductsExcel(input: ProductExportQueryInput, signal?: AbortSignal): Promise<void> {
158 await authorizedGetBlobDownload({ 162 await authorizedGetBlobDownload({
159 path: `${PATH}/export-products-excel`, 163 path: `${PATH}/export-products-excel`,
@@ -177,9 +181,9 @@ export async function importProductsBatch(file: File, signal?: AbortSignal): Pro @@ -177,9 +181,9 @@ export async function importProductsBatch(file: File, signal?: AbortSignal): Pro
177 } 181 }
178 182
179 export async function updateProductsBulk(body: { items: ProductBulkUpdateItemVo[] }): Promise<ProductBulkUpdateResultDto> { 183 export async function updateProductsBulk(body: { items: ProductBulkUpdateItemVo[] }): Promise<ProductBulkUpdateResultDto> {
180 - // ABP 约定控制器:`Update*Async` 多为 PUT,POST 会 405 184 + // ABP:`UpdateProductsBulkAsync` → `products-bulk`(勿写 `update-products-bulk`)
181 return api.requestJson<ProductBulkUpdateResultDto>({ 185 return api.requestJson<ProductBulkUpdateResultDto>({
182 - path: `${PATH}/update-products-bulk`, 186 + path: `${PATH}/products-bulk`,
183 method: "PUT", 187 method: "PUT",
184 body, 188 body,
185 }); 189 });
美国版/Food Labeling Management Platform/src/services/reportsService.ts
1 import { ApiError, createApiClient } from "../lib/apiClient"; 1 import { ApiError, createApiClient } from "../lib/apiClient";
2 -import { authorizedGetBlobDownload } from "../lib/batchFileHttp"; 2 +import { authorizedPostBlobDownload } from "../lib/batchFileHttp";
3 import type { 3 import type {
4 LabelReportData, 4 LabelReportData,
5 LabelReportQueryInput, 5 LabelReportQueryInput,
@@ -357,7 +357,7 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi @@ -357,7 +357,7 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi
357 `/api/app${REPORTS_PREFIX}/export-print-log-pdf${buildPrintLogExportQuery(input)}` 357 `/api/app${REPORTS_PREFIX}/export-print-log-pdf${buildPrintLogExportQuery(input)}`
358 ); 358 );
359 const token = getTokenForFetch(); 359 const token = getTokenForFetch();
360 - const res = await fetch(path, { method: "GET", headers: token ? { Authorization: `Bearer ${token}` } : {} }); 360 + const res = await fetch(path, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {} });
361 const ct = res.headers.get("content-type") ?? ""; 361 const ct = res.headers.get("content-type") ?? "";
362 if (!res.ok) { 362 if (!res.ok) {
363 if (ct.includes("application/json")) { 363 if (ct.includes("application/json")) {
@@ -387,7 +387,7 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi @@ -387,7 +387,7 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi
387 } 387 }
388 388
389 export async function exportPrintLogExcel(input: ReportsPrintLogQueryInput, signal?: AbortSignal): Promise<void> { 389 export async function exportPrintLogExcel(input: ReportsPrintLogQueryInput, signal?: AbortSignal): Promise<void> {
390 - await authorizedGetBlobDownload({ 390 + await authorizedPostBlobDownload({
391 path: `${REPORTS_PREFIX}/export-print-log-excel`, 391 path: `${REPORTS_PREFIX}/export-print-log-excel`,
392 query: { 392 query: {
393 Sorting: input.sorting ?? "PrintedAt desc", 393 Sorting: input.sorting ?? "PrintedAt desc",
@@ -410,7 +410,7 @@ export async function exportLabelReportPdf(input: LabelReportQueryInput): Promis @@ -410,7 +410,7 @@ export async function exportLabelReportPdf(input: LabelReportQueryInput): Promis
410 `/api/app${REPORTS_PREFIX}/export-label-report-pdf${buildLabelReportExportQuery(input)}` 410 `/api/app${REPORTS_PREFIX}/export-label-report-pdf${buildLabelReportExportQuery(input)}`
411 ); 411 );
412 const token = getTokenForFetch(); 412 const token = getTokenForFetch();
413 - const res = await fetch(path, { method: "GET", headers: token ? { Authorization: `Bearer ${token}` } : {} }); 413 + const res = await fetch(path, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {} });
414 const ct = res.headers.get("content-type") ?? ""; 414 const ct = res.headers.get("content-type") ?? "";
415 if (!res.ok) { 415 if (!res.ok) {
416 if (ct.includes("application/json")) { 416 if (ct.includes("application/json")) {
美国版/Food Labeling Management Platform/src/services/roleService.ts
@@ -67,7 +67,7 @@ export type RoleExportQuery = { @@ -67,7 +67,7 @@ export type RoleExportQuery = {
67 state?: boolean; 67 state?: boolean;
68 }; 68 };
69 69
70 -/** GET /api/app/role/export-pdf — 与角色列表筛选字段一致 */ 70 +/** POST /api/app/role/export-pdf — ABP `Export*` 为 POST */
71 export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSignal): Promise<Blob> { 71 export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSignal): Promise<Blob> {
72 const baseUrl = getBaseUrl(); 72 const baseUrl = getBaseUrl();
73 const token = getToken(); 73 const token = getToken();
@@ -81,7 +81,7 @@ export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSigna @@ -81,7 +81,7 @@ export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSigna
81 ); 81 );
82 const headers: Record<string, string> = {}; 82 const headers: Record<string, string> = {};
83 if (token) headers.Authorization = `Bearer ${token}`; 83 if (token) headers.Authorization = `Bearer ${token}`;
84 - const res = await fetch(url, { method: "GET", headers, signal }); 84 + const res = await fetch(url, { method: "POST", headers, signal });
85 if (!res.ok) { 85 if (!res.ok) {
86 const ct = res.headers.get("content-type") ?? ""; 86 const ct = res.headers.get("content-type") ?? "";
87 let msg = "Export failed."; 87 let msg = "Export failed.";
美国版/Food Labeling Management Platform/src/services/teamMemberService.ts
1 -import { ApiError, createApiClient } from "../lib/apiClient";  
2 -import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp"; 1 +import { createApiClient } from "../lib/apiClient";
  2 +import { authorizedPostBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
3 import type { 3 import type {
4 PagedResultDto, 4 PagedResultDto,
5 TeamMemberCreateInput, 5 TeamMemberCreateInput,
@@ -277,7 +277,7 @@ export type TeamMemberBulkUpdateResultDto = { @@ -277,7 +277,7 @@ export type TeamMemberBulkUpdateResultDto = {
277 }; 277 };
278 278
279 export async function downloadTeamMemberImportTemplate(signal?: AbortSignal): Promise<void> { 279 export async function downloadTeamMemberImportTemplate(signal?: AbortSignal): Promise<void> {
280 - await authorizedGetBlobDownload({ 280 + await authorizedPostBlobDownload({
281 path: `${PATH}/download-team-member-import-template`, 281 path: `${PATH}/download-team-member-import-template`,
282 defaultFileName: "Team-Member-template.xlsx", 282 defaultFileName: "Team-Member-template.xlsx",
283 signal, 283 signal,
@@ -285,7 +285,7 @@ export async function downloadTeamMemberImportTemplate(signal?: AbortSignal): Pr @@ -285,7 +285,7 @@ export async function downloadTeamMemberImportTemplate(signal?: AbortSignal): Pr
285 } 285 }
286 286
287 export async function exportTeamMembersPdf(input: TeamMemberExportQueryInput, signal?: AbortSignal): Promise<void> { 287 export async function exportTeamMembersPdf(input: TeamMemberExportQueryInput, signal?: AbortSignal): Promise<void> {
288 - await authorizedGetBlobDownload({ 288 + await authorizedPostBlobDownload({
289 path: `${PATH}/export-team-members-pdf`, 289 path: `${PATH}/export-team-members-pdf`,
290 query: { 290 query: {
291 Keyword: input.keyword, 291 Keyword: input.keyword,
@@ -308,53 +308,14 @@ export async function importTeamMembersBatch(file: File, signal?: AbortSignal): @@ -308,53 +308,14 @@ export async function importTeamMembersBatch(file: File, signal?: AbortSignal):
308 }); 308 });
309 } 309 }
310 310
311 -function bulkItemToUpdateInput(item: TeamMemberBulkUpdateItemVo): TeamMemberUpdateInput {  
312 - const phoneStr = item.phone == null ? null : String(item.phone).trim() || null;  
313 - const input: TeamMemberUpdateInput = {  
314 - fullName: item.fullName,  
315 - userName: item.userName,  
316 - email: item.email ?? null,  
317 - phone: phoneStr,  
318 - roleId: item.roleId,  
319 - locationIds: item.locationIds ?? [],  
320 - state: item.state,  
321 - };  
322 - if (item.password && String(item.password).trim()) {  
323 - input.password = item.password;  
324 - }  
325 - return input;  
326 -}  
327 -  
328 -// 部分环境 bulk 路径与 `PUT /team-member/{id}` 冲突;不改后端时改为逐条 PUT。  
329 export async function updateTeamMembersBulk( 311 export async function updateTeamMembersBulk(
330 body: { items: TeamMemberBulkUpdateItemVo[] }, 312 body: { items: TeamMemberBulkUpdateItemVo[] },
331 ): Promise<TeamMemberBulkUpdateResultDto> { 313 ): Promise<TeamMemberBulkUpdateResultDto> {
332 - const ZERO = "00000000-0000-0000-0000-000000000000";  
333 - const errors: NonNullable<TeamMemberBulkUpdateResultDto["errors"]> = [];  
334 - let successCount = 0;  
335 - let failCount = 0;  
336 -  
337 - for (let i = 0; i < body.items.length; i += 1) {  
338 - const item = body.items[i];  
339 - const rowNumber = i + 1;  
340 - const id = (item.id ?? "").trim();  
341 - if (!id || id.toLowerCase() === ZERO) continue;  
342 -  
343 - try {  
344 - await updateTeamMember(id, bulkItemToUpdateInput(item));  
345 - successCount += 1;  
346 - } catch (e) {  
347 - failCount += 1;  
348 - const message =  
349 - e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Update failed.";  
350 - errors.push({ rowNumber, id, message });  
351 - }  
352 - }  
353 -  
354 - return {  
355 - successCount,  
356 - failCount,  
357 - errors: errors.length ? errors : undefined,  
358 - }; 314 + // ABP 约定 URL:`UpdateTeamMembersBulkAsync` 去掉动词前缀后为 `team-members-bulk`(勿用 `update-...`,否则会被 `PUT …/{id}` 当成 Guid)
  315 + return api.requestJson<TeamMemberBulkUpdateResultDto>({
  316 + path: `${PATH}/team-members-bulk`,
  317 + method: "PUT",
  318 + body,
  319 + });
359 } 320 }
360 321
项目相关文档/批量导入导出接口说明.md
@@ -113,9 +113,9 @@ @@ -113,9 +113,9 @@
113 | 项目 | 说明 | 113 | 项目 | 说明 |
114 |------|------| 114 |------|------|
115 | 方法 | `UpdateLocationsBulkAsync` | 115 | 方法 | `UpdateLocationsBulkAsync` |
116 -| HTTP | `POST` | 116 +| HTTP | `PUT` |
117 | Content-Type | `application/json` | 117 | Content-Type | `application/json` |
118 -| 常见路径 | `/api/app/location/update-locations-bulk` | 118 +| 常见路径 | `/api/app/location/locations-bulk`(ABP 会去掉方法名中的 `Update` 前缀,不是 `update-locations-bulk`) |
119 | Body | `LocationBulkUpdateInputVo` | 119 | Body | `LocationBulkUpdateInputVo` |
120 120
121 **请求体 `LocationBulkUpdateInputVo`** 121 **请求体 `LocationBulkUpdateInputVo`**
@@ -207,9 +207,9 @@ @@ -207,9 +207,9 @@
207 | 项目 | 说明 | 207 | 项目 | 说明 |
208 |------|------| 208 |------|------|
209 | 方法 | `UpdateTeamMembersBulkAsync` | 209 | 方法 | `UpdateTeamMembersBulkAsync` |
210 -| HTTP | `POST` | 210 +| HTTP | `PUT` |
211 | Content-Type | `application/json` | 211 | Content-Type | `application/json` |
212 -| 常见路径 | `/api/app/team-member/update-team-members-bulk` | 212 +| 常见路径 | `/api/app/team-member/team-members-bulk`(勿写 `update-team-members-bulk`,否则易命中 `PUT …/{id}` 导致 Guid 校验错误) |
213 | Body | `TeamMemberBulkUpdateInputVo`:`items` 为 `TeamMemberBulkUpdateItemVo` 数组 | 213 | Body | `TeamMemberBulkUpdateInputVo`:`items` 为 `TeamMemberBulkUpdateItemVo` 数组 |
214 214
215 每行含 **`id`(成员 Guid)** 及与单条 **`PUT /api/app/team-member/{id}`** 相同的字段(`password` 可空表示不改密码)。`id` 为全零 GUID 的项忽略。整单规则与 Location 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。 215 每行含 **`id`(成员 Guid)** 及与单条 **`PUT /api/app/team-member/{id}`** 相同的字段(`password` 可空表示不改密码)。`id` 为全零 GUID 的项忽略。整单规则与 Location 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。
@@ -270,9 +270,9 @@ @@ -270,9 +270,9 @@
270 | 项目 | 说明 | 270 | 项目 | 说明 |
271 |------|------| 271 |------|------|
272 | 方法 | `UpdateProductsBulkAsync` | 272 | 方法 | `UpdateProductsBulkAsync` |
273 -| HTTP | `POST` | 273 +| HTTP | `PUT` |
274 | Content-Type | `application/json` | 274 | Content-Type | `application/json` |
275 -| 常见路径 | `/api/app/product/update-products-bulk` | 275 +| 常见路径 | `/api/app/product/products-bulk` |
276 | Body | `ProductBulkUpdateInputVo`:`items` 为 `ProductBulkUpdateItemVo` 数组 | 276 | Body | `ProductBulkUpdateInputVo`:`items` 为 `ProductBulkUpdateItemVo` 数组 |
277 277
278 每行含 **`id`(产品主键字符串,与列表/详情返回的 `id` 一致)** 及与单条 **`PUT /api/app/product/{id}`** 相同的 body 字段(`ProductUpdateInputVo` / `ProductCreateInputVo` 形状:`productCode`、`productName`、`categoryId`、`productImageUrl`、`state`、`locationIds`)。`id` 为空或仅空白的项**忽略**。整单规则与 Location / Team Member 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。 278 每行含 **`id`(产品主键字符串,与列表/详情返回的 `id` 一致)** 及与单条 **`PUT /api/app/product/{id}`** 相同的 body 字段(`ProductUpdateInputVo` / `ProductCreateInputVo` 形状:`productCode`、`productName`、`categoryId`、`productImageUrl`、`state`、`locationIds`)。`id` 为空或仅空白的项**忽略**。整单规则与 Location / Team Member 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。
@@ -366,7 +366,7 @@ curl -X POST &quot;$BASE/api/app/location/import-locations-batch&quot; \ @@ -366,7 +366,7 @@ curl -X POST &quot;$BASE/api/app/location/import-locations-batch&quot; \
366 -H "Authorization: TOKEN" \ 366 -H "Authorization: TOKEN" \
367 -F "file=@./Location-Manager-template.xlsx" 367 -F "file=@./Location-Manager-template.xlsx"
368 368
369 -curl -X POST "$BASE/api/app/location/update-locations-bulk" \ 369 +curl -X PUT "$BASE/api/app/location/locations-bulk" \
370 -H "Authorization: TOKEN" \ 370 -H "Authorization: TOKEN" \
371 -H "Content-Type: application/json" \ 371 -H "Content-Type: application/json" \
372 -d "{\"items\":[{\"id\":\"YOUR_LOCATION_ID\",\"locationName\":\"UNCC store\",\"state\":true}]}" 372 -d "{\"items\":[{\"id\":\"YOUR_LOCATION_ID\",\"locationName\":\"UNCC store\",\"state\":true}]}"
@@ -384,7 +384,7 @@ curl -X POST &quot;$BASE/api/app/team-member/import-team-members-batch&quot; \ @@ -384,7 +384,7 @@ curl -X POST &quot;$BASE/api/app/team-member/import-team-members-batch&quot; \
384 -H "Authorization: TOKEN" \ 384 -H "Authorization: TOKEN" \
385 -F "file=@./Team-Member-template.xlsx" 385 -F "file=@./Team-Member-template.xlsx"
386 386
387 -curl -X POST "$BASE/api/app/team-member/update-team-members-bulk" \ 387 +curl -X PUT "$BASE/api/app/team-member/team-members-bulk" \
388 -H "Authorization: TOKEN" \ 388 -H "Authorization: TOKEN" \
389 -H "Content-Type: application/json" \ 389 -H "Content-Type: application/json" \
390 -d "{\"items\":[{\"id\":\"YOUR_USER_GUID\",\"fullName\":\"John\",\"userName\":\"john@example.com\",\"email\":\"john@example.com\",\"phone\":789654444,\"roleId\":\"ROLE_GUID\",\"locationIds\":[\"LOCATION_GUID\"],\"state\":true}]}" 390 -d "{\"items\":[{\"id\":\"YOUR_USER_GUID\",\"fullName\":\"John\",\"userName\":\"john@example.com\",\"email\":\"john@example.com\",\"phone\":789654444,\"roleId\":\"ROLE_GUID\",\"locationIds\":[\"LOCATION_GUID\"],\"state\":true}]}"
@@ -416,7 +416,7 @@ curl -X POST &quot;$BASE/api/app/product/import-products-batch&quot; \ @@ -416,7 +416,7 @@ curl -X POST &quot;$BASE/api/app/product/import-products-batch&quot; \
416 -H "Authorization: TOKEN" \ 416 -H "Authorization: TOKEN" \
417 -F "file=@./Product-Manager-template.xlsx" 417 -F "file=@./Product-Manager-template.xlsx"
418 418
419 -curl -X POST "$BASE/api/app/product/update-products-bulk" \ 419 +curl -X PUT "$BASE/api/app/product/products-bulk" \
420 -H "Authorization: TOKEN" \ 420 -H "Authorization: TOKEN" \
421 -H "Content-Type: application/json" \ 421 -H "Content-Type: application/json" \
422 -d "{\"items\":[{\"id\":\"YOUR_PRODUCT_ID\",\"productName\":\"Tuna & Bacon Sub\",\"categoryId\":\"CATEGORY_ID\",\"productCode\":\"40001\",\"state\":true,\"locationIds\":[\"LOCATION_GUID_1\",\"LOCATION_GUID_2\"]}]}" 422 -d "{\"items\":[{\"id\":\"YOUR_PRODUCT_ID\",\"productName\":\"Tuna & Bacon Sub\",\"categoryId\":\"CATEGORY_ID\",\"productCode\":\"40001\",\"state\":true,\"locationIds\":[\"LOCATION_GUID_1\",\"LOCATION_GUID_2\"]}]}"