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 159 <view class="food-grid">
160 160 <view
161 161 v-for="product in pCat.products"
162   - :key="productCardKey(product)"
  162 + :key="product.productId"
163 163 class="food-card"
164 164 @click="handleProductClick(product, pCat.name)"
165 165 >
... ... @@ -408,16 +408,8 @@ function productPhotoSrc(p: UsAppLabelingProductNodeDto): string {
408 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 412 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string {
419   - const direct = (p.templateLabelSizeText ?? '').trim()
420   - if (direct) return direct
421 413 const types = p.labelTypes || []
422 414 if (types.length === 0) return '—'
423 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 40 }))
41 41 return {
42 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 43 productName: String(x?.productName ?? x?.ProductName ?? ''),
49 44 productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
50 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  
12 12 export interface UsAppLabelingProductNodeDto {
13 13 productId: string
14   - /** 与 productId 组合唯一标识一张卡(多模板拆卡) */
15   - templateId?: string | null
16   - templateCode?: string | null
17   - /** 当前卡片模板尺寸,与接口 templateLabelSizeText 对齐 */
18   - templateLabelSizeText?: string | null
19 14 productName: string
20 15 productCode: string
21 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 5 <meta charset="UTF-8" />
6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 7 <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-ChVLtgeV.js"></script>
  8 + <script type="module" crossorigin src="/assets/index-D3QH2BRm.js"></script>
9 9 <link rel="stylesheet" crossorigin href="/assets/index-DLL5VTnd.css">
10 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 3 import { Button } from "../ui/button";
3 4 import {
4 5 Dialog,
... ... @@ -9,6 +10,7 @@ import {
9 10 DialogTitle,
10 11 } from "../ui/dialog";
11 12 import { Label } from "../ui/label";
  13 +import { cn } from "../ui/utils";
12 14 import { toast } from "sonner";
13 15 import { ApiError } from "../../lib/apiClient";
14 16  
... ... @@ -24,6 +26,25 @@ export type BatchImportDialogProps = {
24 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 48 export function BatchImportDialog({
28 49 open,
29 50 onOpenChange,
... ... @@ -33,15 +54,42 @@ export function BatchImportDialog({
33 54 onImportFile,
34 55 downloadingTemplate = false,
35 56 }: BatchImportDialogProps) {
  57 + const fileInputId = useId();
36 58 const inputRef = useRef<HTMLInputElement | null>(null);
37 59 const [file, setFile] = useState<File | null>(null);
38 60 const [busy, setBusy] = useState(false);
  61 + const [dragActive, setDragActive] = useState(false);
39 62  
40 63 const reset = () => {
41 64 setFile(null);
  65 + setDragActive(false);
42 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 93 return (
46 94 <Dialog
47 95 open={open}
... ... @@ -50,42 +98,141 @@ export function BatchImportDialog({
50 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 114 </DialogHeader>
58   - <div className="flex flex-col gap-4 py-2">
  115 +
  116 + <div className="flex flex-col gap-3">
59 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 121 <input
62   - id="batch-import-file"
  122 + id={fileInputId}
63 123 ref={inputRef}
64 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 228 </div>
73 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 231 <DialogFooter className="gap-2 sm:gap-0">
86 232 <Button
87 233 type="button"
88 234 variant="outline"
  235 + size="sm"
89 236 onClick={() => {
90 237 reset();
91 238 onOpenChange(false);
... ... @@ -95,6 +242,8 @@ export function BatchImportDialog({
95 242 </Button>
96 243 <Button
97 244 type="button"
  245 + size="sm"
  246 + className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
98 247 disabled={!file || busy}
99 248 onClick={async () => {
100 249 if (!file) return;
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
... ... @@ -226,7 +226,7 @@ export function LabelCategoriesView() {
226 226 };
227 227  
228 228 return (
229   - <div className="h-full flex flex-col">
  229 + <div className="flex h-full min-h-0 flex-col">
230 230 <div className="pb-4">
231 231 <div className="flex flex-col gap-4">
232 232 <div className="flex flex-nowrap items-center gap-3">
... ... @@ -268,20 +268,19 @@ export function LabelCategoriesView() {
268 268 <TableHead className="font-bold text-gray-900 w-[200px]">Category Photo</TableHead>
269 269 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead>
270 270 <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead>
271   - <TableHead className="font-bold text-gray-900">Last Edited</TableHead>
272 271 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead>
273 272 </TableRow>
274 273 </TableHeader>
275 274 <TableBody>
276 275 {loading ? (
277 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 278 Loading...
280 279 </TableCell>
281 280 </TableRow>
282 281 ) : categories.length === 0 ? (
283 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 284 No results.
286 285 </TableCell>
287 286 </TableRow>
... ... @@ -299,9 +298,6 @@ export function LabelCategoriesView() {
299 298 </Badge>
300 299 </TableCell>
301 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 301 <TableCell className="text-center">
306 302 <Popover
307 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 4 /** 左侧标签库:四类分组(与产品图一致);打印时输入项可带 config 以正确显示为 input */
6 5 const ELEMENT_CATEGORIES: {
... ... @@ -9,65 +8,65 @@ const ELEMENT_CATEGORIES: {
9 8 items: { label: string; type: ElementType; config?: Record<string, unknown> }[];
10 9 }[] = [
11 10 {
12   - title: 'Template',
  11 + title: "Template",
13 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 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 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 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 61 config: {
63   - inputType: 'options',
64   - multipleOptionId: '',
  62 + inputType: "options",
  63 + multipleOptionId: "",
65 64 selectedOptionValues: [],
66   - text: 'Text',
67   - fontFamily: 'Arial',
  65 + text: "Text",
  66 + fontFamily: "Arial",
68 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 124 };
126 125 }
127 126  
  127 +/**
  128 + * 左侧元素库。纵向滚动由父级(index 左侧列容器)的 overflow-y:auto 负责,
  129 + * 避免预编译 CSS 缺少 min-h-0 / flex-1 时内层滚动高度为 0 导致底部橙色区被裁切。
  130 + */
128 131 export function ElementsPanel({ onAddElement }: ElementsPanelProps) {
129 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 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 136 style={{ backgroundColor: "#eff6ff", borderBottomColor: "#93c5fd" }}
134 137 >
135 138 Elements
136 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 158 </div>
164   - ))}
165   - </div>
166   - </ScrollArea>
  159 + </div>
  160 + ))}
  161 + </div>
167 162 </div>
168 163 );
169 164 }
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
... ... @@ -1450,7 +1450,16 @@ export function LabelCanvas({
1450 1450 };
1451 1451  
1452 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 1463 {/* Label Preview 标题 + 网格/预览/缩放 */}
1455 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 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 1580 <div
1572 1581 ref={scrollContainerRef}
1573 1582 className={cn(
1574   - "flex-1 min-h-0 overflow-auto relative",
  1583 + "overflow-auto relative",
1575 1584 isSpacePressed ? "cursor-grab active:cursor-grabbing" : ""
1576 1585 )}
  1586 + style={{ flex: "1 1 0%", minHeight: 0, overflow: "auto", position: "relative" }}
1577 1587 onPointerDown={handleContainerPointerDown}
1578 1588 onPointerMove={handleContainerPointerMove}
1579 1589 onPointerUp={handleContainerPointerUp}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
1 1 import React, { useEffect, useState } from 'react';
2   -import { ScrollArea } from '../../ui/scroll-area';
3 2 import { Input } from '../../ui/input';
4 3 import { Button } from '../../ui/button';
5 4 import { Label } from '../../ui/label';
... ... @@ -82,12 +81,12 @@ export function PropertiesPanel({
82 81 if (selectedElement) {
83 82 const isBlankElement = isBlankSpaceElement(selectedElement);
84 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 86 Properties (Element)
88 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 90 <div className="grid grid-cols-2 gap-2">
92 91 <div>
93 92 <Label className="text-xs">X</Label>
... ... @@ -218,23 +217,23 @@ export function PropertiesPanel({
218 217 </div>
219 218 )}
220 219 </div>
221   - </ScrollArea>
  220 + </div>
222 221 </div>
223 222 );
224 223 }
225 224  
226 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 228 Properties (Element)
230 229 </div>
231   - <ScrollArea className="flex-1">
  230 + <div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain">
232 231 <div className="p-3">
233 232 <div className="rounded-md border border-blue-100 bg-blue-50/50 p-3 text-xs text-blue-900">
234 233 Select an element on the canvas to edit its properties.
235 234 </div>
236 235 </div>
237   - </ScrollArea>
  236 + </div>
238 237 </div>
239 238 );
240 239 }
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
... ... @@ -407,7 +407,16 @@ export function LabelTemplateEditor({
407 407 }, [template]);
408 408  
409 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 420 {/* Toolbar */}
412 421 <div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 bg-white shrink-0">
413 422 <Button variant="outline" size="sm" onClick={onClose}>
... ... @@ -591,12 +600,44 @@ export function LabelTemplateEditor({
591 600 </div>
592 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 628 <ElementsPanel onAddElement={addElement} />
598 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 641 <LabelCanvas
601 642 template={template}
602 643 canvasBorder={canvasBorder}
... ... @@ -613,19 +654,18 @@ export function LabelTemplateEditor({
613 654 hideToolbarPresetSize
614 655 />
615 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 669 <PropertiesPanel
630 670 template={template}
631 671 selectedElement={selectedElement}
... ... @@ -636,6 +676,18 @@ export function LabelTemplateEditor({
636 676 />
637 677 </div>
638 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 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 479 );
480 480  
481 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 497 <LabelTemplateEditor
486 498 templateId={editingTemplateId}
487 499 initialTemplate={initialTemplate}
... ... @@ -490,7 +502,9 @@ export function LabelTemplatesView({ onTemplateEditorOverlayChange }: LabelTempl
490 502 />
491 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 510 <DeleteLabelTemplateDialog
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTypesView.tsx
... ... @@ -145,7 +145,7 @@ export function LabelTypesView() {
145 145 };
146 146  
147 147 return (
148   - <div className="h-full flex flex-col">
  148 + <div className="flex h-full min-h-0 flex-col">
149 149 <div className="pb-4">
150 150 <div className="flex flex-col gap-4">
151 151 <div className="flex flex-nowrap items-center gap-3">
... ... @@ -186,20 +186,19 @@ export function LabelTypesView() {
186 186 <TableHead className="font-bold text-gray-900 w-[200px]">Type Code</TableHead>
187 187 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead>
188 188 <TableHead className="font-bold text-gray-900 w-[100px]">Order</TableHead>
189   - <TableHead className="font-bold text-gray-900">Last Edited</TableHead>
190 189 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead>
191 190 </TableRow>
192 191 </TableHeader>
193 192 <TableBody>
194 193 {loading ? (
195 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 196 Loading...
198 197 </TableCell>
199 198 </TableRow>
200 199 ) : types.length === 0 ? (
201 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 202 No results.
204 203 </TableCell>
205 204 </TableRow>
... ... @@ -214,9 +213,6 @@ export function LabelTypesView() {
214 213 </Badge>
215 214 </TableCell>
216 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 216 <TableCell className="text-center">
221 217 <Popover
222 218 open={actionsOpenForId === item.id}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
... ... @@ -108,21 +108,6 @@ function labelRowProductsText(item: LabelDto): string {
108 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 111 /** 详情 / 列表行 → 编辑表单(列表接口可能缺 ID 字段,需再以 GET 详情补全) */
127 112 function labelDtoToUpdateForm(d: LabelDto): LabelUpdateInput {
128 113 const ids = d.productIds;
... ... @@ -733,20 +718,19 @@ export function LabelsList({ openCreateSeq = 0, onOpenCreateIntentConsumed }: La
733 718 <TableHead className="font-bold text-gray-900 w-[120px]">Template</TableHead>
734 719 <TableHead className="font-bold text-gray-900">Products</TableHead>
735 720 <TableHead className="font-bold text-gray-900 w-[100px]">State</TableHead>
736   - <TableHead className="font-bold text-gray-900">Last Edited</TableHead>
737 721 <TableHead className="font-bold text-gray-900 text-center w-[100px]">Actions</TableHead>
738 722 </TableRow>
739 723 </TableHeader>
740 724 <TableBody>
741 725 {loading ? (
742 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 728 Loading...
745 729 </TableCell>
746 730 </TableRow>
747 731 ) : labels.length === 0 ? (
748 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 734 No results.
751 735 </TableCell>
752 736 </TableRow>
... ... @@ -773,9 +757,6 @@ export function LabelsList({ openCreateSeq = 0, onOpenCreateIntentConsumed }: La
773 757 {item.state === true ? "Active" : "Inactive"}
774 758 </Badge>
775 759 </TableCell>
776   - <TableCell className="text-gray-500 tabular-nums font-numeric whitespace-nowrap">
777   - {labelRowLastEdited(item)}
778   - </TableCell>
779 760 <TableCell className="text-center">
780 761 <Popover
781 762 open={actionsOpenForId === item.id}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsView.tsx
... ... @@ -50,7 +50,8 @@ export function LabelsView({
50 50  
51 51 return (
52 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 56 {/* Tabs:模板编辑器全屏编辑时隐藏 */}
56 57 {!templateEditorHidesTabs && (
... ... @@ -78,8 +79,11 @@ export function LabelsView({
78 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 87 {currentView === 'Labels' && (
84 88 <div className="min-h-0 flex-1 overflow-auto">
85 89 <LabelsList
... ... @@ -98,8 +102,11 @@ export function LabelsView({
98 102 <LabelTypesView />
99 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 110 <LabelTemplatesView onTemplateEditorOverlayChange={handleTemplateEditorOverlay} />
104 111 </div>
105 112 )}
... ...
美国版/Food Labeling Management Platform/src/components/layout/Layout.tsx
... ... @@ -27,7 +27,17 @@ export function Layout({
27 27 {!hideAppChrome && (
28 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 41 {!hideAppChrome && (
32 42 <>
33 43 <Header title={currentView} onSettingsClick={() => setCurrentView('Support')} />
... ... @@ -49,11 +59,35 @@ export function Layout({
49 59 <main
50 60 className={
51 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 91 </main>
58 92 </div>
59 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 55 import type { GroupListItem } from "../../types/group";
56 56 import type { PartnerListItem } from "../../types/partner";
57 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 61 const LOCATION_PG_NONE = "__none__";
61 62  
... ... @@ -149,7 +150,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
149 150 const [actionsOpenForId, setActionsOpenForId] = useState<string | null>(null);
150 151 const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
151 152 const [bulkImportOpen, setBulkImportOpen] = useState(false);
152   - const [bulkEditOpen, setBulkEditOpen] = useState(false);
  153 + const [locationBulkEditPage, setLocationBulkEditPage] = useState(false);
153 154 const [bulkEditSeed, setBulkEditSeed] = useState<LocationDto[]>([]);
154 155 const [tmplDownloading, setTmplDownloading] = useState(false);
155 156 const [excelExporting, setExcelExporting] = useState(false);
... ... @@ -272,7 +273,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
272 273 };
273 274  
274 275 const toolbarSection = (
275   - <div className="pb-4">
  276 + <div className={cn(!locationBulkEditPage && "pb-4", locationBulkEditPage && "hidden")}>
276 277 <div className="flex flex-col gap-4">
277 278 <div className="flex flex-nowrap items-center gap-3">
278 279 <Input
... ... @@ -362,7 +363,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
362 363 return;
363 364 }
364 365 setBulkEditSeed(seed);
365   - setBulkEditOpen(true);
  366 + setLocationBulkEditPage(true);
366 367 }}
367 368 >
368 369 Bulk Edit
... ... @@ -385,7 +386,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
385 386 <Table>
386 387 <TableHeader>
387 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 390 <Checkbox
390 391 checked={locations.length > 0 && locations.every((l) => selectedIds.has(l.id))}
391 392 onCheckedChange={(c) => {
... ... @@ -427,7 +428,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
427 428 ) : (
428 429 locations.map((loc) => (
429 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 432 <Checkbox
432 433 checked={selectedIds.has(loc.id)}
433 434 onCheckedChange={(c) => {
... ... @@ -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 591 const dialogs = (
571 592 <>
572 593 <CreateLocationDialog
... ... @@ -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 657 return (
646 658 <div className="h-full flex flex-col min-h-0">
647 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 661 {dialogs}
650 662 </div>
651 663 );
... ... @@ -654,7 +666,7 @@ export function LocationsView({ renderBeforeTabs }: LocationsViewProps = {}) {
654 666 return (
655 667 <div className="h-full flex flex-col min-h-0">
656 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 670 {dialogs}
659 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 2 import { Button } from "../ui/button";
3 3 import { Input } from "../ui/input";
4 4 import { Switch } from "../ui/switch";
5   -import {
6   - Dialog,
7   - DialogContent,
8   - DialogDescription,
9   - DialogHeader,
10   - DialogTitle,
11   -} from "../ui/dialog";
12 5 import { toast } from "sonner";
13 6 import { ApiError } from "../../lib/apiClient";
14 7 import { updateLocationsBulk, type LocationBulkUpdateItemVo } from "../../services/locationService";
... ... @@ -22,11 +15,9 @@ function isValidBulkId(id: string): boolean {
22 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 19 seed: LocationDto[];
  20 + onBack: () => void;
30 21 onSaved: () => void;
31 22 };
32 23  
... ... @@ -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 47 const [rows, setRows] = useState<RowState[]>([]);
77 48 const [saving, setSaving] = useState(false);
78 49  
79   - const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]);
80   -
81 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 54 const updateRow = (idx: number, patch: Partial<RowState>) => {
89 55 setRows((prev) => {
... ... @@ -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 62 const handleSave = async () => {
104 63 const items: LocationBulkUpdateItemVo[] = rows
105 64 .filter((r) => isValidBulkId(r.id))
... ... @@ -121,7 +80,7 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo
121 80 }));
122 81  
123 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 84 return;
126 85 }
127 86  
... ... @@ -139,7 +98,7 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo
139 98 toast.message("Errors (first 5)", { description: preview });
140 99 }
141 100 onSaved();
142   - onOpenChange(false);
  101 + onBack();
143 102 } catch (e) {
144 103 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
145 104 toast.error("Bulk save failed", { description: msg });
... ... @@ -149,131 +108,133 @@ export function LocationBulkEditDialog({ open, onOpenChange, seed, onSaved }: Lo
149 108 };
150 109  
151 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 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 1096 <Table>
1097 1097 <TableHeader>
1098 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 1100 <Checkbox
1101 1101 checked={members.length > 0 && members.every((m) => selectedMemberIds.has(m.id))}
1102 1102 onCheckedChange={(c) => {
... ... @@ -1131,7 +1131,7 @@ export function PeopleView({ initialSubTab, onInitialSubTabConsumed }: PeopleVie
1131 1131 ) : (
1132 1132 members.map((m) => (
1133 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 1135 <Checkbox
1136 1136 checked={selectedMemberIds.has(m.id)}
1137 1137 onCheckedChange={(c) => {
... ...
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
... ... @@ -81,7 +81,8 @@ import type { ProductCategoryDto, ProductCategoryCreateInput } from &quot;../../types
81 81 import { SearchableSelect } from "../ui/searchable-select";
82 82 import { SearchableMultiSelect } from "../ui/searchable-multi-select";
83 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 86 import {
86 87 Pagination,
87 88 PaginationContent,
... ... @@ -170,7 +171,7 @@ export function ProductsView() {
170 171 const [actionsOpenId, setActionsOpenId] = useState<string | null>(null);
171 172 const [selectedProductIds, setSelectedProductIds] = useState<Set<string>>(() => new Set());
172 173 const [bulkImportOpen, setBulkImportOpen] = useState(false);
173   - const [bulkEditOpen, setBulkEditOpen] = useState(false);
  174 + const [productBulkEditPage, setProductBulkEditPage] = useState(false);
174 175 const [bulkEditSeed, setBulkEditSeed] = useState<ProductDto[]>([]);
175 176 const [tmplDownloading, setTmplDownloading] = useState(false);
176 177 const [excelExporting, setExcelExporting] = useState(false);
... ... @@ -362,6 +363,10 @@ export function ProductsView() {
362 363 reloadCategoryCatalog();
363 364 };
364 365  
  366 + useEffect(() => {
  367 + if (activeTab !== "products") setProductBulkEditPage(false);
  368 + }, [activeTab]);
  369 +
365 370 const locationOptions = useMemo(
366 371 () =>
367 372 locations.map((loc) => ({
... ... @@ -394,7 +399,7 @@ export function ProductsView() {
394 399  
395 400 return (
396 401 <div className="h-full flex flex-col">
397   - <div className="pb-4">
  402 + <div className={cn("pb-4", productBulkEditPage && "hidden")}>
398 403 <div className="flex flex-col gap-3">
399 404 <div className="flex flex-wrap items-center gap-3">
400 405 <div
... ... @@ -518,6 +523,23 @@ export function ProductsView() {
518 523 </div>
519 524  
520 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 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 544 {activeTab === "products" && (
523 545 <>
... ... @@ -568,7 +590,7 @@ export function ProductsView() {
568 590 return;
569 591 }
570 592 setBulkEditSeed(seed);
571   - setBulkEditOpen(true);
  593 + setProductBulkEditPage(true);
572 594 }}
573 595 >
574 596 <Edit className="w-4 h-4" /> Bulk Edit
... ... @@ -602,7 +624,7 @@ export function ProductsView() {
602 624 <Table>
603 625 <TableHeader>
604 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 628 <Checkbox
607 629 checked={products.length > 0 && products.every((p) => selectedProductIds.has(p.id))}
608 630 onCheckedChange={(c) => {
... ... @@ -644,7 +666,7 @@ export function ProductsView() {
644 666 const active = p.state !== false;
645 667 return (
646 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 670 <Checkbox
649 671 checked={selectedProductIds.has(p.id)}
650 672 onCheckedChange={(c) => {
... ... @@ -995,6 +1017,8 @@ export function ProductsView() {
995 1017 </div>
996 1018 </div>
997 1019 )}
  1020 + </>
  1021 + )}
998 1022 </div>
999 1023  
1000 1024 <ProductFormDialog
... ... @@ -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 1076 <ProductCategoryFormDialog
1064 1077 open={isProductCategoryDialogOpen}
1065 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 2 import { Button } from "../ui/button";
3 3 import { Input } from "../ui/input";
4 4 import { Switch } from "../ui/switch";
5 5 import {
6   - Dialog,
7   - DialogContent,
8   - DialogDescription,
9   - DialogHeader,
10   - DialogTitle,
11   -} from "../ui/dialog";
12   -import {
13 6 Select,
14 7 SelectContent,
15 8 SelectItem,
... ... @@ -26,11 +19,10 @@ function isValidBulkProductId(id: string): boolean {
26 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 23 seed: ProductDto[];
33 24 categories: ProductCategoryDto[];
  25 + onBack: () => void;
34 26 onSaved: () => void;
35 27 };
36 28  
... ... @@ -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 43 function parseIdsCsv(s: string): string[] {
64 44 return s
65 45 .split(/[,;|\s]+/)
... ... @@ -67,23 +47,16 @@ function parseIdsCsv(s: string): string[] {
67 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 51 const [rows, setRows] = useState<RowState[]>([]);
72 52 const [saving, setSaving] = useState(false);
73 53 const [locCsvByIdx, setLocCsvByIdx] = useState<string[]>([]);
74 54  
75   - const minRows = useMemo(() => Math.max(seed.length + 10, 10), [seed.length]);
76   -
77 55 useEffect(() => {
78   - if (!open) return;
79 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 61 const updateRow = (idx: number, patch: Partial<RowState>) => {
89 62 setRows((prev) => {
... ... @@ -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 69 const syncLocIds = (idx: number, csv: string) => {
102 70 const nextCsv = [...locCsvByIdx];
103 71 nextCsv[idx] = csv;
... ... @@ -119,7 +87,7 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on
119 87 }));
120 88  
121 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 91 return;
124 92 }
125 93  
... ... @@ -130,7 +98,7 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on
130 98 description: `Success: ${res.successCount}, failed: ${res.failCount}`,
131 99 });
132 100 onSaved();
133   - onOpenChange(false);
  101 + onBack();
134 102 } catch (e) {
135 103 const msg = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Save failed.";
136 104 toast.error("Bulk save failed", { description: msg });
... ... @@ -140,96 +108,89 @@ export function ProductBulkEditDialog({ open, onOpenChange, seed, categories, on
140 108 };
141 109  
142 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 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 70 <th
71 71 data-slot="table-head"
72 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 74 className,
75 75 )}
76 76 {...props}
... ... @@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps&lt;&quot;td&quot;&gt;) {
83 83 <td
84 84 data-slot="table-cell"
85 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 87 className,
88 88 )}
89 89 {...props}
... ...
美国版/Food Labeling Management Platform/src/lib/batchFileHttp.ts
... ... @@ -40,8 +40,11 @@ function parseFileNameFromContentDisposition(h: string | null): string | null {
40 40  
41 41 function getAbpErrorMessage(payload: unknown): string | null {
42 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 50 function unwrapEnvelope<T>(payload: unknown): T {
... ... @@ -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 156 * multipart 上传文件,解析 JSON 响应(兼容 ABP 包裹 `{ data, succeeded }`)。
109 157 */
110 158 export async function authorizedPostMultipartJson<T>(opts: {
... ...
美国版/Food Labeling Management Platform/src/services/groupService.ts
... ... @@ -114,7 +114,7 @@ export type GroupExportQuery = {
114 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 118 export async function exportGroupsPdf(input: GroupExportQuery, signal?: AbortSignal): Promise<Blob> {
119 119 const baseUrl = getBaseUrl();
120 120 const token = getToken();
... ... @@ -129,7 +129,7 @@ export async function exportGroupsPdf(input: GroupExportQuery, signal?: AbortSig
129 129 );
130 130 const headers: Record<string, string> = {};
131 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 133 if (!res.ok) {
134 134 const ct = res.headers.get("content-type") ?? "";
135 135 let msg = "Export failed.";
... ...
美国版/Food Labeling Management Platform/src/services/locationService.ts
1 1 import { createApiClient } from "../lib/apiClient";
2   -import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
  2 +import { authorizedPostBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
3 3 import type {
4 4 LocationCreateInput,
5 5 LocationDto,
... ... @@ -151,7 +151,7 @@ export type LocationBulkUpdateResultDto = {
151 151 };
152 152  
153 153 export async function downloadLocationImportTemplate(signal?: AbortSignal): Promise<void> {
154   - await authorizedGetBlobDownload({
  154 + await authorizedPostBlobDownload({
155 155 path: "/location/download-location-import-template",
156 156 defaultFileName: "Location-Manager-template.xlsx",
157 157 signal,
... ... @@ -159,7 +159,7 @@ export async function downloadLocationImportTemplate(signal?: AbortSignal): Prom
159 159 }
160 160  
161 161 export async function exportLocationsExcel(input: LocationExportQueryInput, signal?: AbortSignal): Promise<void> {
162   - await authorizedGetBlobDownload({
  162 + await authorizedPostBlobDownload({
163 163 path: "/location/export-locations-excel",
164 164 query: {
165 165 Sorting: input.sorting,
... ... @@ -185,9 +185,9 @@ export async function importLocationsBatch(file: File, signal?: AbortSignal): Pr
185 185 export async function updateLocationsBulk(
186 186 body: { items: LocationBulkUpdateItemVo[] },
187 187 ): Promise<LocationBulkUpdateResultDto> {
188   - // ABP 约定控制器:`Update*Async` 多为 PUT,POST 会 405
  188 + // ABP:`UpdateLocationsBulkAsync` → `locations-bulk`(勿写 `update-locations-bulk`)
189 189 return api.requestJson<LocationBulkUpdateResultDto>({
190   - path: "/location/update-locations-bulk",
  190 + path: "/location/locations-bulk",
191 191 method: "PUT",
192 192 body,
193 193 });
... ...
美国版/Food Labeling Management Platform/src/services/partnerService.ts
... ... @@ -118,7 +118,7 @@ export type PartnerExportQuery = {
118 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 122 export async function exportPartnersPdf(input: PartnerExportQuery, signal?: AbortSignal): Promise<Blob> {
123 123 const baseUrl = getBaseUrl();
124 124 const token = getToken();
... ... @@ -132,7 +132,7 @@ export async function exportPartnersPdf(input: PartnerExportQuery, signal?: Abor
132 132 );
133 133 const headers: Record<string, string> = {};
134 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 136 if (!res.ok) {
137 137 const ct = res.headers.get("content-type") ?? "";
138 138 let msg = "Export failed.";
... ...
美国版/Food Labeling Management Platform/src/services/productService.ts
1 1 import { createApiClient } from "../lib/apiClient";
2   -import { authorizedGetBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
  2 +import { authorizedGetBlobDownload, authorizedPostBlobDownload, authorizedPostMultipartJson } from "../lib/batchFileHttp";
3 3 import type {
4 4 ProductCreateInput,
5 5 ProductDto,
... ... @@ -147,13 +147,17 @@ export type ProductBulkUpdateResultDto = {
147 147 };
148 148  
149 149 export async function downloadProductImportTemplate(signal?: AbortSignal): Promise<void> {
150   - await authorizedGetBlobDownload({
  150 + await authorizedPostBlobDownload({
151 151 path: `${PATH}/download-product-import-template`,
152 152 defaultFileName: "Product-Manager-template.xlsx",
153 153 signal,
154 154 });
155 155 }
156 156  
  157 +/**
  158 + * 与后端 `ExportProductsExcelAsync([FromQuery] ProductGetListInputVo)` 一致:GET + 查询参数。
  159 + * 勿用 POST,否则易与 `GET /product/{id}` 等路由混淆或 405。
  160 + */
157 161 export async function exportProductsExcel(input: ProductExportQueryInput, signal?: AbortSignal): Promise<void> {
158 162 await authorizedGetBlobDownload({
159 163 path: `${PATH}/export-products-excel`,
... ... @@ -177,9 +181,9 @@ export async function importProductsBatch(file: File, signal?: AbortSignal): Pro
177 181 }
178 182  
179 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 185 return api.requestJson<ProductBulkUpdateResultDto>({
182   - path: `${PATH}/update-products-bulk`,
  186 + path: `${PATH}/products-bulk`,
183 187 method: "PUT",
184 188 body,
185 189 });
... ...
美国版/Food Labeling Management Platform/src/services/reportsService.ts
1 1 import { ApiError, createApiClient } from "../lib/apiClient";
2   -import { authorizedGetBlobDownload } from "../lib/batchFileHttp";
  2 +import { authorizedPostBlobDownload } from "../lib/batchFileHttp";
3 3 import type {
4 4 LabelReportData,
5 5 LabelReportQueryInput,
... ... @@ -357,7 +357,7 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi
357 357 `/api/app${REPORTS_PREFIX}/export-print-log-pdf${buildPrintLogExportQuery(input)}`
358 358 );
359 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 361 const ct = res.headers.get("content-type") ?? "";
362 362 if (!res.ok) {
363 363 if (ct.includes("application/json")) {
... ... @@ -387,7 +387,7 @@ export async function exportPrintLogPdf(input: ReportsPrintLogQueryInput): Promi
387 387 }
388 388  
389 389 export async function exportPrintLogExcel(input: ReportsPrintLogQueryInput, signal?: AbortSignal): Promise<void> {
390   - await authorizedGetBlobDownload({
  390 + await authorizedPostBlobDownload({
391 391 path: `${REPORTS_PREFIX}/export-print-log-excel`,
392 392 query: {
393 393 Sorting: input.sorting ?? "PrintedAt desc",
... ... @@ -410,7 +410,7 @@ export async function exportLabelReportPdf(input: LabelReportQueryInput): Promis
410 410 `/api/app${REPORTS_PREFIX}/export-label-report-pdf${buildLabelReportExportQuery(input)}`
411 411 );
412 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 414 const ct = res.headers.get("content-type") ?? "";
415 415 if (!res.ok) {
416 416 if (ct.includes("application/json")) {
... ...
美国版/Food Labeling Management Platform/src/services/roleService.ts
... ... @@ -67,7 +67,7 @@ export type RoleExportQuery = {
67 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 71 export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSignal): Promise<Blob> {
72 72 const baseUrl = getBaseUrl();
73 73 const token = getToken();
... ... @@ -81,7 +81,7 @@ export async function exportRolesPdf(input: RoleExportQuery, signal?: AbortSigna
81 81 );
82 82 const headers: Record<string, string> = {};
83 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 85 if (!res.ok) {
86 86 const ct = res.headers.get("content-type") ?? "";
87 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 3 import type {
4 4 PagedResultDto,
5 5 TeamMemberCreateInput,
... ... @@ -277,7 +277,7 @@ export type TeamMemberBulkUpdateResultDto = {
277 277 };
278 278  
279 279 export async function downloadTeamMemberImportTemplate(signal?: AbortSignal): Promise<void> {
280   - await authorizedGetBlobDownload({
  280 + await authorizedPostBlobDownload({
281 281 path: `${PATH}/download-team-member-import-template`,
282 282 defaultFileName: "Team-Member-template.xlsx",
283 283 signal,
... ... @@ -285,7 +285,7 @@ export async function downloadTeamMemberImportTemplate(signal?: AbortSignal): Pr
285 285 }
286 286  
287 287 export async function exportTeamMembersPdf(input: TeamMemberExportQueryInput, signal?: AbortSignal): Promise<void> {
288   - await authorizedGetBlobDownload({
  288 + await authorizedPostBlobDownload({
289 289 path: `${PATH}/export-team-members-pdf`,
290 290 query: {
291 291 Keyword: input.keyword,
... ... @@ -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 311 export async function updateTeamMembersBulk(
330 312 body: { items: TeamMemberBulkUpdateItemVo[] },
331 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 113 | 项目 | 说明 |
114 114 |------|------|
115 115 | 方法 | `UpdateLocationsBulkAsync` |
116   -| HTTP | `POST` |
  116 +| HTTP | `PUT` |
117 117 | Content-Type | `application/json` |
118   -| 常见路径 | `/api/app/location/update-locations-bulk` |
  118 +| 常见路径 | `/api/app/location/locations-bulk`(ABP 会去掉方法名中的 `Update` 前缀,不是 `update-locations-bulk`) |
119 119 | Body | `LocationBulkUpdateInputVo` |
120 120  
121 121 **请求体 `LocationBulkUpdateInputVo`**
... ... @@ -207,9 +207,9 @@
207 207 | 项目 | 说明 |
208 208 |------|------|
209 209 | 方法 | `UpdateTeamMembersBulkAsync` |
210   -| HTTP | `POST` |
  210 +| HTTP | `PUT` |
211 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 213 | Body | `TeamMemberBulkUpdateInputVo`:`items` 为 `TeamMemberBulkUpdateItemVo` 数组 |
214 214  
215 215 每行含 **`id`(成员 Guid)** 及与单条 **`PUT /api/app/team-member/{id}`** 相同的字段(`password` 可空表示不改密码)。`id` 为全零 GUID 的项忽略。整单规则与 Location 批量编辑相同(`MaxBulkUpdateItems`、至少一条有效 `id` 等)。
... ... @@ -270,9 +270,9 @@
270 270 | 项目 | 说明 |
271 271 |------|------|
272 272 | 方法 | `UpdateProductsBulkAsync` |
273   -| HTTP | `POST` |
  273 +| HTTP | `PUT` |
274 274 | Content-Type | `application/json` |
275   -| 常见路径 | `/api/app/product/update-products-bulk` |
  275 +| 常见路径 | `/api/app/product/products-bulk` |
276 276 | Body | `ProductBulkUpdateInputVo`:`items` 为 `ProductBulkUpdateItemVo` 数组 |
277 277  
278 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 366 -H "Authorization: TOKEN" \
367 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 370 -H "Authorization: TOKEN" \
371 371 -H "Content-Type: application/json" \
372 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 384 -H "Authorization: TOKEN" \
385 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 388 -H "Authorization: TOKEN" \
389 389 -H "Content-Type: application/json" \
390 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 416 -H "Authorization: TOKEN" \
417 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 420 -H "Authorization: TOKEN" \
421 421 -H "Content-Type: application/json" \
422 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\"]}]}"
... ...