Blame view

美国版/Food Labeling Management Platform/src/components/ui/image-url-upload.tsx 6.59 KB
3af4878d   杨鑫   产品 标签 关联
1
2
3
4
5
6
7
8
9
10
11
12
  import React, { useRef, useState } from "react";
  import { Plus, X } from "lucide-react";
  import { toast } from "sonner";
  import { cn } from "./utils";
  import { PICTURE_UPLOAD_MAX_BYTES, resolvePictureUrlForDisplay, uploadImageFile } from "../../services/imageUploadService";
  
  export type ImageUrlUploadProps = {
    value: string;
    onChange: (url: string) => void;
    disabled?: boolean;
    /** 辅助说明,显示在方框下方 */
    hint?: string;
b04bc3a5   杨鑫   打印日志 重新打印
13
    /** 空状态主文案(默认无,仅加号;需要时传入如 "Click to upload") */
3af4878d   杨鑫   产品 标签 关联
14
15
16
17
18
19
20
    emptyLabel?: string;
    accept?: string;
    /** 默认 5MB,与平台 picture 上传接口一致 */
    maxSizeMb?: number;
    className?: string;
    /** 上传区域宽度(tailwind),默认 max-w-[200px] */
    boxClassName?: string;
923d50c0   杨鑫   更新bug
21
22
    /** 固定上传区边长(px);优先于仅依赖 Tailwind 的 boxClassName,避免预构建 CSS 缺类名时撑满整行 */
    boxSizePx?: number;
3af4878d   杨鑫   产品 标签 关联
23
24
25
26
27
28
29
30
31
32
33
    /** 传给后端的 multipart `subDir`(如 category、product) */
    uploadSubDir?: string;
    /** 明确单图:隐藏多选、提示文案 */
    oneImageOnly?: boolean;
  };
  
  export function ImageUrlUpload({
    value,
    onChange,
    disabled,
    hint,
b04bc3a5   杨鑫   打印日志 重新打印
34
    emptyLabel = "",
3af4878d   杨鑫   产品 标签 关联
35
36
37
38
    accept = "image/jpeg,image/png,image/webp,image/gif",
    maxSizeMb = PICTURE_UPLOAD_MAX_BYTES / (1024 * 1024),
    className,
    boxClassName,
923d50c0   杨鑫   更新bug
39
    boxSizePx,
3af4878d   杨鑫   产品 标签 关联
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
    uploadSubDir,
    oneImageOnly,
  }: ImageUrlUploadProps) {
    const inputRef = useRef<HTMLInputElement>(null);
    const [uploading, setUploading] = useState(false);
  
    const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      e.target.value = "";
      if (!file) return;
      if (!file.type.startsWith("image/")) {
        toast.error("Please select an image file.");
        return;
      }
      if (file.size > maxSizeMb * 1024 * 1024) {
        toast.error(`Image must be ${maxSizeMb} MB or smaller.`);
        return;
      }
  
      setUploading(true);
      try {
        const url = await uploadImageFile(file, { subDir: uploadSubDir });
        onChange(url);
        toast.success("Image uploaded.");
      } catch (err: unknown) {
        const msg = err instanceof Error ? err.message : String(err);
        toast.error("Upload failed", { description: msg || undefined });
      } finally {
        setUploading(false);
      }
    };
  
    const busy = disabled || uploading;
  
    const openPicker = () => {
      if (!busy) inputRef.current?.click();
    };
  
923d50c0   杨鑫   更新bug
78
79
80
81
82
83
84
85
86
87
88
89
90
91
    const fixedSize = typeof boxSizePx === "number" && boxSizePx > 0 ? Math.round(boxSizePx) : 0;
    const hasCustomBox = fixedSize > 0 || Boolean(boxClassName?.trim());
    const boxSizeStyle: React.CSSProperties | undefined =
      fixedSize > 0
        ? {
            width: fixedSize,
            height: fixedSize,
            minWidth: fixedSize,
            minHeight: fixedSize,
            maxWidth: fixedSize,
            maxHeight: fixedSize,
            boxSizing: "border-box",
          }
        : undefined;
63289723   杨鑫   提交
92
    const boxShell = hasCustomBox
923d50c0   杨鑫   更新bug
93
      ? "box-border shrink-0 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
63289723   杨鑫   提交
94
      : "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2";
3af4878d   杨鑫   产品 标签 关联
95
96
  
    return (
923d50c0   杨鑫   更新bug
97
      <div className={cn("space-y-2", hasCustomBox && "w-fit max-w-full", className)}>
3af4878d   杨鑫   产品 标签 关联
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          className="sr-only"
          disabled={busy}
          multiple={false}
          onChange={onFileChange}
        />
  
        {!value ? (
          <button
            type="button"
            disabled={busy}
            onClick={openPicker}
b04bc3a5   杨鑫   打印日志 重新打印
113
            aria-label={emptyLabel || "Upload image"}
923d50c0   杨鑫   更新bug
114
            style={boxSizeStyle}
3af4878d   杨鑫   产品 标签 关联
115
            className={cn(
63289723   杨鑫   提交
116
117
              boxShell,
              hasCustomBox ? boxClassName : null,
b04bc3a5   杨鑫   打印日志 重新打印
118
119
120
121
              "flex border-2 border-dashed border-gray-300 bg-gray-50/80 text-gray-400",
              emptyLabel && !uploading
                ? "flex-col items-center justify-center gap-2"
                : "items-center justify-center",
3af4878d   杨鑫   产品 标签 关联
122
123
              "hover:border-gray-400 hover:bg-gray-100/90 hover:text-gray-500",
              "disabled:pointer-events-none disabled:opacity-50",
3af4878d   杨鑫   产品 标签 关联
124
125
            )}
          >
b04bc3a5   杨鑫   打印日志 重新打印
126
127
128
129
            {uploading ? (
              <span className="px-3 text-center text-sm font-normal text-gray-500">Uploading…</span>
            ) : (
              <>
923d50c0   杨鑫   更新bug
130
131
132
133
134
135
136
                <Plus
                  className={cn(
                    "shrink-0 stroke-[1.25]",
                    fixedSize > 0 && fixedSize <= 120 ? "h-8 w-8" : "h-10 w-10",
                  )}
                  aria-hidden
                />
b04bc3a5   杨鑫   打印日志 重新打印
137
138
139
140
141
142
143
                {emptyLabel ? (
                  <span className="px-3 text-center text-sm font-normal leading-tight text-gray-400">
                    {emptyLabel}
                  </span>
                ) : null}
              </>
            )}
3af4878d   杨鑫   产品 标签 关联
144
145
146
          </button>
        ) : (
          <div
923d50c0   杨鑫   更新bug
147
            style={boxSizeStyle}
3af4878d   杨鑫   产品 标签 关联
148
            className={cn(
63289723   杨鑫   提交
149
150
151
              "group relative overflow-hidden border-2 border-dashed border-gray-300 bg-gray-50/80",
              boxShell,
              hasCustomBox ? boxClassName : null,
3af4878d   杨鑫   产品 标签 关联
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
            )}
          >
            <button
              type="button"
              disabled={busy}
              onClick={openPicker}
              className="relative h-full w-full p-0"
              aria-label="Replace image"
            >
              <img
                src={resolvePictureUrlForDisplay(value)}
                alt=""
                className="h-full w-full object-contain"
                onError={(ev) => {
                  (ev.target as HTMLImageElement).style.opacity = "0.2";
                }}
              />
              <span className="absolute inset-0 flex items-center justify-center bg-black/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-black/45 group-hover:opacity-100">
                Click to replace
              </span>
            </button>
            <button
              type="button"
              disabled={busy}
              onClick={(e) => {
                e.stopPropagation();
                onChange("");
              }}
              className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-white/95 text-gray-600 shadow-sm ring-1 ring-gray-200 transition hover:bg-white hover:text-gray-900 disabled:opacity-50"
              aria-label="Remove image"
            >
              <X className="h-4 w-4" />
            </button>
          </div>
        )}
  
        {oneImageOnly ? (
          <p className="text-xs text-muted-foreground">One image only. Replace or clear to change.</p>
        ) : null}
        {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
      </div>
    );
  }