@jiangood/open-admin 3.0.12 → 3.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,21 @@
1
- import React, {useCallback, useEffect, useRef, useState} from "react";
2
- import {Alert, Button, Modal, Space, Upload, message} from "antd";
3
- import {DeleteOutlined, PlusOutlined, ScissorOutlined} from "@ant-design/icons";
1
+ import React from "react";
2
+ import {Button, Divider, Modal, Radio, Select, Space, Upload, message} from "antd";
3
+ import {DeleteOutlined, EyeOutlined, PlusOutlined} from "@ant-design/icons";
4
4
  import Compressor from "compressorjs";
5
5
  import Cropper from "cropperjs";
6
6
  import "cropperjs/dist/cropper.css";
7
7
  import {HttpUtils} from "../../utils";
8
+ import {ObjectUtils} from "../../utils";
8
9
  import {UrlUtils} from "../../utils";
9
10
  import type {FieldProps} from '../types';
10
11
 
11
- interface FieldUploadImageProps extends FieldProps<string> {
12
+ export interface FieldUploadImageProps extends FieldProps<string> {
12
13
  /** 最大上传数量,默认 1 */
13
14
  maxCount?: number;
14
- /** 压缩图目标宽度(强制目标比例),默认 800 */
15
- targetWidth?: number;
16
- /** 压缩图目标高度(强制目标比例),默认 600 */
17
- targetHeight?: number;
18
15
  /** 缩略图最长边,默认 300 */
19
16
  thumbWidth?: number;
20
- /** 压缩质量,默认 0.8 */
21
- quality?: number;
22
- /** 文件可见性,默认 public */
23
- visibility?: 'public' | 'private';
17
+ /** 是否公开免登录访问,默认 true(private 需登录) */
18
+ isPublic?: boolean;
24
19
  /** 接受的文件类型 */
25
20
  accept?: string;
26
21
  }
@@ -30,16 +25,35 @@ interface Dims {
30
25
  height: number;
31
26
  }
32
27
 
33
- /** 弹窗内预览结果:压缩图 + 缩略图 */
28
+ /** 弹窗内当前主图(原图或处理结果),缩略图在确定上传时生成 */
34
29
  interface PreviewResult {
35
30
  cUrl: string;
36
- tUrl: string;
37
31
  cFile: File;
38
- tFile: File;
39
32
  cSize: number;
40
- tSize: number;
41
33
  cdims: Dims;
42
- tdims: Dims;
34
+ }
35
+
36
+ type Tool = 'crop' | 'auto';
37
+
38
+ interface FieldUploadImageState {
39
+ maxCount: number;
40
+ thumbWidth: number;
41
+ isPublic: boolean;
42
+ accept: string;
43
+
44
+ objectNames: string[];
45
+ modalOpen: boolean;
46
+ originalUrl?: string;
47
+ originalDims?: Dims;
48
+ originalSize: number;
49
+ preview?: PreviewResult;
50
+ tool?: Tool;
51
+ cropperReady: boolean;
52
+ cropRatio: Dims | null;
53
+ uploading: boolean;
54
+ fullPreviewUrl?: string;
55
+ compressWidth: number;
56
+ compressSize: number;
43
57
  }
44
58
 
45
59
  function formatSize(bytes: number): string {
@@ -48,6 +62,26 @@ function formatSize(bytes: number): string {
48
62
  return (bytes / 1024 / 1024).toFixed(2) + ' MB';
49
63
  }
50
64
 
65
+ function gcd(a: number, b: number): number {
66
+ return b === 0 ? a : gcd(b, a % b);
67
+ }
68
+
69
+ /** 比例转最简整数比字符串,如 800:600 → "4:3" */
70
+ function formatRatio(w: number, h: number): string {
71
+ const g = gcd(w, h) || 1;
72
+ return `${w / g}:${h / g}`;
73
+ }
74
+
75
+ /** 裁切比例选项:null 表示自由裁切;desc 标注常见场景用途 */
76
+ const CROP_RATIOS: Array<{label: string; ratio: Dims | null; desc?: string}> = [
77
+ {label: '自由', ratio: null},
78
+ {label: '1:1', ratio: {width: 1, height: 1}, desc: '方形'},
79
+ {label: '4:3', ratio: {width: 4, height: 3}, desc: '商品图'},
80
+ {label: '3:4', ratio: {width: 3, height: 4}, desc: '竖图'},
81
+ {label: '3:2', ratio: {width: 3, height: 2}, desc: 'Banner'},
82
+ {label: '16:9', ratio: {width: 16, height: 9}, desc: '横幅'},
83
+ ];
84
+
51
85
  function readDims(url: string): Promise<Dims> {
52
86
  return new Promise((resolve) => {
53
87
  const img = new Image();
@@ -57,12 +91,14 @@ function readDims(url: string): Promise<Dims> {
57
91
  });
58
92
  }
59
93
 
60
- function compressToFile(source: Blob, maxWidth: number, maxHeight: number, quality: number): Promise<File> {
94
+ function compressToFile(source: Blob, options: {maxWidth?: number; maxHeight?: number; width?: number; height?: number; quality?: number}): Promise<File> {
61
95
  return new Promise((resolve, reject) => {
62
96
  new Compressor(source, {
63
- maxWidth,
64
- maxHeight,
65
- quality,
97
+ maxWidth: options.maxWidth,
98
+ maxHeight: options.maxHeight,
99
+ width: options.width,
100
+ height: options.height,
101
+ quality: options.quality,
66
102
  success(result) {
67
103
  const file = result instanceof File ? result : new File([result], 'image.jpg', {type: result.type || 'image/jpeg'});
68
104
  resolve(file);
@@ -72,75 +108,198 @@ function compressToFile(source: Blob, maxWidth: number, maxHeight: number, quali
72
108
  });
73
109
  }
74
110
 
75
- export function FieldUploadImage(props: FieldUploadImageProps) {
76
- const {
77
- value, onChange, maxCount = 1, targetWidth = 800, targetHeight = 600,
78
- thumbWidth = 300, quality = 0.8, visibility = 'public', accept = 'image/*',
79
- } = props;
80
-
81
- const [objectNames, setObjectNames] = useState<string[]>(() => (value ? value.split(',') : []));
82
- const [modalOpen, setModalOpen] = useState(false);
83
- const [originalUrl, setOriginalUrl] = useState<string>();
84
- const [originalDims, setOriginalDims] = useState<Dims>();
85
- const [originalSize, setOriginalSize] = useState(0);
86
- const [preview, setPreview] = useState<PreviewResult>();
87
- const [needsCrop, setNeedsCrop] = useState(false);
88
- const [cropperActive, setCropperActive] = useState(false);
89
- const [uploading, setUploading] = useState(false);
90
- const [fullPreviewUrl, setFullPreviewUrl] = useState<string>();
91
-
92
- const imgRef = useRef<HTMLImageElement>(null);
93
- const cropperRef = useRef<Cropper>();
94
- const selectedFileRef = useRef<File>();
95
- const previewUrlsRef = useRef<string[]>([]);
96
-
97
- const targetRatio = targetWidth / targetHeight;
98
-
99
- // 父组件 value 变化时同步
100
- useEffect(() => {
101
- const parsed = value ? value.split(',') : [];
102
- setObjectNames(parsed);
103
- }, [value]);
104
-
105
- const revokePreviewUrls = useCallback(() => {
106
- previewUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
107
- previewUrlsRef.current = [];
108
- }, []);
109
-
110
- const closeModal = useCallback(() => {
111
- if (cropperRef.current) {
112
- cropperRef.current.destroy();
113
- cropperRef.current = undefined;
111
+ /**
112
+ * 压缩到指定体积内:先按 maxWidth 等比缩放,再二分质量逼近目标体积;
113
+ * 若最低质量仍超目标,则进一步缩小宽度。
114
+ */
115
+ async function compressToTarget(source: Blob, maxWidth: number | undefined, targetBytes: number): Promise<File> {
116
+ const maxW = maxWidth || undefined;
117
+ if (!targetBytes || targetBytes <= 0) {
118
+ return compressToFile(source, {maxWidth: maxW, maxHeight: maxW});
119
+ }
120
+ // 二分查找满足目标体积的最大质量(JPEG 质量范围 0.1 ~ 1)
121
+ let best = await compressToFile(source, {maxWidth: maxW, maxHeight: maxW, quality: 1});
122
+ if (best.size <= targetBytes) return best;
123
+ let lo = 0.1;
124
+ let hi = 1;
125
+ for (let i = 0; i < 5; i++) {
126
+ const mid = Number(((lo + hi) / 2).toFixed(2));
127
+ const f = await compressToFile(source, {maxWidth: maxW, maxHeight: maxW, quality: mid});
128
+ if (f.size <= targetBytes) {
129
+ best = f;
130
+ lo = mid;
131
+ } else {
132
+ hi = mid;
114
133
  }
115
- setModalOpen(false);
116
- setCropperActive(false);
117
- setNeedsCrop(false);
118
- setUploading(false);
119
- setOriginalUrl(undefined);
120
- setOriginalDims(undefined);
121
- setOriginalSize(0);
122
- setPreview(undefined);
123
- if (originalUrl) URL.revokeObjectURL(originalUrl);
124
- revokePreviewUrls();
125
- }, [originalUrl, revokePreviewUrls]);
134
+ }
135
+ // 最低质量仍超目标:缩小宽度重试(最多缩小到原宽 50%)
136
+ let w = maxW;
137
+ while (best.size > targetBytes && w && w > 320) {
138
+ w = Math.round(w * 0.8);
139
+ best = await compressToFile(source, {maxWidth: w, maxHeight: w, quality: 0.1});
140
+ }
141
+ return best;
142
+ }
143
+
144
+ export class FieldUploadImage extends React.Component<FieldUploadImageProps, FieldUploadImageState> {
145
+
146
+ state: FieldUploadImageState = {
147
+ // 传入的参数
148
+ maxCount: 1,
149
+ thumbWidth: 300,
150
+ isPublic: true,
151
+ accept: 'image/*',
152
+
153
+ // 内部参数
154
+ objectNames: [],
155
+ modalOpen: false,
156
+ originalSize: 0,
157
+ cropperReady: false,
158
+ cropRatio: {width: 4, height: 3},
159
+ uploading: false,
160
+ compressWidth: 1920,
161
+ compressSize: 500 * 1024,
162
+ };
163
+
164
+ private imgRef = React.createRef<HTMLImageElement>();
165
+ private cropperRef?: Cropper;
166
+ private selectedFileRef?: File;
167
+ private previewUrlsRef: string[] = [];
168
+
169
+ constructor(props: FieldUploadImageProps) {
170
+ super(props);
171
+ ObjectUtils.copyPropertyIfPresent(props, this.state);
172
+ if (this.props.value) this.state.objectNames = this.props.value.split(',');
173
+ }
174
+
175
+ componentDidUpdate(prevProps: FieldUploadImageProps, prevState: FieldUploadImageState) {
176
+ const next: Partial<FieldUploadImageState> = {};
177
+ if (this.props.maxCount !== prevProps.maxCount) next.maxCount = this.props.maxCount;
178
+ if (this.props.thumbWidth !== prevProps.thumbWidth) next.thumbWidth = this.props.thumbWidth;
179
+ if (this.props.isPublic !== prevProps.isPublic) next.isPublic = this.props.isPublic;
180
+ if (this.props.accept !== prevProps.accept) next.accept = this.props.accept;
181
+
182
+ const prevValue = prevProps.value ?? null;
183
+ const curValue = this.props.value ?? null;
184
+ if (curValue !== prevValue) {
185
+ next.objectNames = curValue ? curValue.split(',') : [];
186
+ }
187
+
188
+ if (Object.keys(next).length > 0) this.setState(next as FieldUploadImageState);
189
+
190
+ // 进入/退出裁切工具时初始化或销毁 Cropper
191
+ if (prevState.modalOpen !== this.state.modalOpen || prevState.tool !== this.state.tool) {
192
+ this.destroyCropper();
193
+ if (this.state.modalOpen && this.state.tool === 'crop') {
194
+ this.initCropper();
195
+ }
196
+ }
197
+
198
+ // 切换裁切比例时仅更新约束,保留当前裁切框位置(避免重建后贴边)
199
+ if (prevState.cropRatio !== this.state.cropRatio && this.cropperRef) {
200
+ if (!this.state.cropRatio) {
201
+ const box = this.cropperRef.getCropBoxData();
202
+ this.cropperRef.setAspectRatio(NaN);
203
+ this.cropperRef.setCropBoxData(box);
204
+ } else {
205
+ this.cropperRef.setAspectRatio(this.state.cropRatio.width / this.state.cropRatio.height);
206
+ }
207
+ }
208
+ }
209
+
210
+ componentWillUnmount() {
211
+ if (this.cropperRef) {
212
+ this.cropperRef.destroy();
213
+ this.cropperRef = undefined;
214
+ }
215
+ this.revokePreviewUrls();
216
+ }
217
+
218
+ private destroyCropper() {
219
+ if (this.cropperRef) {
220
+ this.cropperRef.destroy();
221
+ this.cropperRef = undefined;
222
+ }
223
+ this.setState({cropperReady: false});
224
+ }
225
+
226
+ private initCropper() {
227
+ const el = this.imgRef.current;
228
+ if (!el) return;
229
+ const init = () => {
230
+ if (this.cropperRef) this.cropperRef.destroy();
231
+ this.cropperRef = new Cropper(el, {
232
+ aspectRatio: this.state.cropRatio ? this.state.cropRatio.width / this.state.cropRatio.height : NaN,
233
+ viewMode: 1,
234
+ autoCropArea: 0.85,
235
+ dragMode: 'move',
236
+ });
237
+ this.setState({cropperReady: true});
238
+ };
239
+ if (el.complete) init();
240
+ else el.addEventListener('load', init, {once: true});
241
+ }
242
+
243
+ private revokePreviewUrls() {
244
+ this.previewUrlsRef.forEach((u) => URL.revokeObjectURL(u));
245
+ this.previewUrlsRef = [];
246
+ }
126
247
 
127
248
  /**
128
- * 基于源文件生成压缩图 + 缩略图(含各自尺寸/大小),用于弹窗展示
249
+ * 处理源文件为当前主图(不压缩,仅加载画布预览),用于裁切/手动处理后更新画布
129
250
  */
130
- const regenerate = useCallback(async (source: Blob) => {
131
- const cFile = await compressToFile(source, targetWidth, targetHeight, quality);
132
- const tFile = await compressToFile(cFile, thumbWidth, thumbWidth, quality);
133
- const cUrl = URL.createObjectURL(cFile);
134
- const tUrl = URL.createObjectURL(tFile);
251
+ private regenerate = async (file: File) => {
252
+ const cUrl = URL.createObjectURL(file);
135
253
  const cdims = await readDims(cUrl);
136
- const tdims = await readDims(tUrl);
137
- revokePreviewUrls();
138
- previewUrlsRef.current = [cUrl, tUrl];
139
- setPreview({cUrl, tUrl, cFile, tFile, cSize: cFile.size, tSize: tFile.size, cdims, tdims});
140
- }, [quality, revokePreviewUrls, targetHeight, targetWidth, thumbWidth]);
141
-
142
- const handleBeforeUpload = useCallback(async (file: File) => {
143
- if (objectNames.length >= maxCount) {
254
+ this.revokePreviewUrls();
255
+ this.previewUrlsRef = [cUrl];
256
+ this.setState({preview: {cUrl, cFile: file, cSize: file.size, cdims}});
257
+ };
258
+
259
+ /**
260
+ * 默认展示原图:不做任何压缩处理
261
+ */
262
+ private loadOriginalPreview = async (file: File, url: string, dims: Dims) => {
263
+ this.revokePreviewUrls();
264
+ this.previewUrlsRef = [];
265
+ this.setState({preview: {cUrl: url, cFile: file, cSize: file.size, cdims: dims}});
266
+ };
267
+
268
+ private closeModal = () => {
269
+ this.destroyCropper();
270
+ this.setState({
271
+ modalOpen: false,
272
+ tool: undefined,
273
+ uploading: false,
274
+ originalUrl: undefined,
275
+ originalDims: undefined,
276
+ originalSize: 0,
277
+ preview: undefined,
278
+ });
279
+ if (this.state.originalUrl) URL.revokeObjectURL(this.state.originalUrl);
280
+ this.revokePreviewUrls();
281
+ };
282
+
283
+ /** 手动压缩:按用户设定的最大宽度与目标体积压缩当前主图 */
284
+ private applyCompress = async (width?: number, size?: number) => {
285
+ // 优先压缩当前主图(裁切结果),否则用原始文件
286
+ const file = this.state.preview?.cFile || this.selectedFileRef;
287
+ if (!file) return;
288
+ try {
289
+ const cFile = await compressToTarget(file, width, size ?? 0);
290
+ const cUrl = URL.createObjectURL(cFile);
291
+ const cdims = await readDims(cUrl);
292
+ this.revokePreviewUrls();
293
+ this.previewUrlsRef = [cUrl];
294
+ this.setState({preview: {cUrl, cFile, cSize: cFile.size, cdims}, tool: undefined});
295
+ message.success(`已压缩:${cdims.width} x ${cdims.height} / ${formatSize(cFile.size)}`);
296
+ } catch (e) {
297
+ message.error('压缩失败');
298
+ }
299
+ };
300
+
301
+ private handleBeforeUpload = async (file: File) => {
302
+ if (this.state.objectNames.length >= this.state.maxCount) {
144
303
  message.warning('已达到最大上传数量');
145
304
  return Upload.LIST_IGNORE;
146
305
  }
@@ -149,186 +308,288 @@ export function FieldUploadImage(props: FieldUploadImageProps) {
149
308
  return Upload.LIST_IGNORE;
150
309
  }
151
310
 
152
- selectedFileRef.current = file;
311
+ this.selectedFileRef = file;
153
312
  const url = URL.createObjectURL(file);
154
- setOriginalUrl(url);
155
- setOriginalSize(file.size);
156
- setModalOpen(true);
157
- setCropperActive(false);
313
+ this.setState({originalUrl: url, originalSize: file.size, tool: undefined, modalOpen: true});
158
314
 
159
315
  try {
160
316
  // EXIF 方向感知地读取原始尺寸
161
317
  const bitmap = await createImageBitmap(file, {imageOrientation: 'from-image'});
162
318
  const dims = {width: bitmap.width, height: bitmap.height};
163
319
  bitmap.close();
164
- setOriginalDims(dims);
165
- const ratioDiff = Math.abs(dims.width / dims.height - targetRatio);
166
- setNeedsCrop(ratioDiff / targetRatio > 0.02);
320
+ this.setState({originalDims: dims});
321
+ // 默认展示原图,不做压缩处理,用户可点击「压缩」按钮手动压缩
322
+ await this.loadOriginalPreview(file, url, dims);
167
323
  } catch (e) {
168
324
  message.error('读取图片失败');
169
- closeModal();
325
+ this.closeModal();
170
326
  return;
171
327
  }
172
328
 
173
- await regenerate(file);
174
329
  return Upload.LIST_IGNORE;
175
- }, [closeModal, maxCount, objectNames.length, regenerate, targetRatio]);
176
-
177
- const startCrop = useCallback(() => {
178
- setCropperActive(true);
179
- setTimeout(() => {
180
- const el = imgRef.current;
181
- if (!el) return;
182
- if (cropperRef.current) cropperRef.current.destroy();
183
- cropperRef.current = new Cropper(el, {
184
- aspectRatio: targetRatio,
185
- viewMode: 1,
186
- autoCropArea: 1,
187
- dragMode: 'move',
188
- });
189
- }, 0);
190
- }, [targetRatio]);
330
+ };
191
331
 
192
- const confirmCrop = useCallback(() => {
193
- const cropper = cropperRef.current;
194
- const file = selectedFileRef.current;
332
+ private confirmCrop = () => {
333
+ const cropper = this.cropperRef;
334
+ const file = this.state.preview?.cFile || this.selectedFileRef;
195
335
  if (!cropper || !file) return;
196
336
  const canvas = cropper.getCroppedCanvas();
197
337
  const mime = file.type || 'image/jpeg';
198
338
  canvas.toBlob(async (blob) => {
199
- if (cropperRef.current) {
200
- cropperRef.current.destroy();
201
- cropperRef.current = undefined;
202
- }
203
- setCropperActive(false);
204
- setNeedsCrop(false);
339
+ this.destroyCropper();
340
+ this.setState({tool: undefined});
205
341
  if (blob) {
206
342
  const croppedFile = new File([blob], 'cropped.jpg', {type: mime});
207
- await regenerate(croppedFile);
343
+ await this.regenerate(croppedFile);
208
344
  }
209
- }, mime, quality);
210
- }, [quality, regenerate]);
345
+ }, mime);
346
+ };
211
347
 
212
- const cancelCrop = useCallback(() => {
213
- if (cropperRef.current) {
214
- cropperRef.current.destroy();
215
- cropperRef.current = undefined;
348
+ /** 重置:丢弃所有处理,回到原始图片 */
349
+ private resetImage = () => {
350
+ const file = this.selectedFileRef;
351
+ if (!file) return;
352
+ if (this.state.originalUrl && this.state.originalDims) {
353
+ this.setState({tool: undefined, cropperReady: false});
354
+ this.loadOriginalPreview(file, this.state.originalUrl, this.state.originalDims);
216
355
  }
217
- setCropperActive(false);
218
- }, []);
356
+ };
219
357
 
220
- const handleConfirm = useCallback(async () => {
221
- if (needsCrop || !preview) return;
222
- setUploading(true);
358
+ private handleConfirm = async () => {
359
+ if (!this.state.preview) return;
360
+ this.setState({uploading: true});
223
361
  try {
362
+ // 确定时才生成缩略图
363
+ const tFile = await compressToFile(this.state.preview.cFile, {maxWidth: this.state.thumbWidth, maxHeight: this.state.thumbWidth});
224
364
  const fd = new FormData();
225
- fd.append('file', preview.cFile);
226
- fd.append('thumb', preview.tFile);
227
- fd.append('visibility', visibility);
365
+ fd.append('file', this.state.preview.cFile);
366
+ fd.append('thumb', tFile);
367
+ fd.append('isPublic', String(this.state.isPublic));
228
368
  const rs = await HttpUtils.post('admin/sysFile/uploadImage', fd, null, {headers: {'Content-Type': 'multipart/form-data'}});
229
- const newNames = [...objectNames, rs.objectName];
230
- setObjectNames(newNames);
231
- onChange?.(newNames.join(','));
232
- closeModal();
369
+ const newNames = [...this.state.objectNames, rs.objectName];
370
+ this.setState({objectNames: newNames});
371
+ this.props.onChange?.(newNames.join(','));
372
+ this.closeModal();
233
373
  } catch (e) {
234
374
  message.error(HttpUtils.extractErrorMessage(e));
235
375
  } finally {
236
- setUploading(false);
376
+ this.setState({uploading: false});
237
377
  }
238
- }, [closeModal, needsCrop, objectNames, onChange, preview, visibility]);
239
-
240
- const removeImage = useCallback((name: string) => {
241
- const newNames = objectNames.filter((n) => n !== name);
242
- setObjectNames(newNames);
243
- onChange?.(newNames.join(','));
244
- }, [objectNames, onChange]);
245
-
246
- const renderPanel = (title: string, url: string | undefined, dims: Dims | undefined, size: number | undefined) => (
247
- <div style={{flex: 1, textAlign: 'center'}}>
248
- <div style={{fontWeight: 600, marginBottom: 8}}>{title}</div>
249
- {url ? (
250
- <img src={url} style={{maxWidth: '100%', maxHeight: 160, display: 'block', margin: '0 auto'}}/>
251
- ) : (
252
- <div style={{height: 120, lineHeight: '120px', color: '#999'}}>生成中...</div>
253
- )}
254
- <div style={{color: '#666', marginTop: 8}}>
255
- {dims ? `${dims.width} x ${dims.height}` : '--'}
256
- {size != null && size > 0 && <span> · {formatSize(size)}</span>}
257
- </div>
258
- </div>
259
- );
260
-
261
- return (
262
- <>
263
- <div>
264
- {objectNames.map((name) => (
265
- <div key={name} style={{position: 'relative', display: 'inline-block', marginRight: 8, verticalAlign: 'top'}}>
266
- <img
267
- src={UrlUtils.contextPath(`/file/${name}?thumb=1`)}
268
- width={80}
269
- height={80}
270
- style={{objectFit: 'cover', borderRadius: 4, cursor: 'pointer'}}
271
- onClick={() => setFullPreviewUrl(UrlUtils.contextPath(`/file/${name}`))}
272
- alt={name}
273
- />
274
- <Button
275
- size="small"
276
- danger
277
- icon={<DeleteOutlined/>}
278
- style={{position: 'absolute', top: -8, right: -8}}
279
- onClick={() => removeImage(name)}
280
- />
281
- </div>
282
- ))}
283
- {objectNames.length < maxCount && (
284
- <Upload accept={accept} showUploadList={false} beforeUpload={handleBeforeUpload} multiple={false}>
285
- <Button icon={<PlusOutlined/>}>选择图片</Button>
286
- </Upload>
287
- )}
288
- </div>
289
-
290
- <Modal
291
- open={modalOpen}
292
- title="图片预览"
293
- width={900}
294
- okText="确定"
295
- cancelText="取消"
296
- onOk={handleConfirm}
297
- onCancel={closeModal}
298
- confirmLoading={uploading}
299
- okButtonProps={{disabled: needsCrop}}
300
- >
301
- {cropperActive ? (
302
- <div>
303
- {originalUrl && <img ref={imgRef} src={originalUrl} style={{maxWidth: '100%'}} alt="待裁切"/>}
304
- <Space style={{marginTop: 12}}>
305
- <Button type="primary" onClick={confirmCrop}>确认裁切</Button>
306
- <Button onClick={cancelCrop}>取消裁切</Button>
307
- </Space>
308
- </div>
309
- ) : (
310
- <>
311
- {needsCrop && (
312
- <Alert type="warning" showIcon
313
- message="图片比例与目标尺寸不符,请点击下方「裁切」按钮按比例处理后再确定"
314
- style={{marginBottom: 12}}/>
315
- )}
316
- <div style={{display: 'flex', gap: 16}}>
317
- {renderPanel('原图', originalUrl, originalDims, originalSize)}
318
- {renderPanel('压缩图', preview?.cUrl, preview?.cdims, preview?.cSize)}
319
- {renderPanel('缩略图', preview?.tUrl, preview?.tdims, preview?.tSize)}
378
+ };
379
+
380
+ private removeImage = (name: string) => {
381
+ const newNames = this.state.objectNames.filter((n) => n !== name);
382
+ this.setState({objectNames: newNames});
383
+ this.props.onChange?.(newNames.join(','));
384
+ };
385
+
386
+ render() {
387
+ const {objectNames, accept, compressWidth, compressSize, cropRatio, cropperReady, fullPreviewUrl, maxCount, modalOpen, originalUrl, preview, tool, uploading} = this.state;
388
+
389
+ // 画布当前显示的图片
390
+ const canvasImg = preview
391
+ ? {url: preview.cUrl, dims: preview.cdims, size: preview.cSize}
392
+ : undefined;
393
+
394
+ // 是否需要压缩:当前图片宽度/体积任一超过设定目标
395
+ const needCompress = !!canvasImg?.dims && (
396
+ (compressWidth > 0 && canvasImg.dims.width > compressWidth) ||
397
+ (compressSize > 0 && canvasImg.size > compressSize)
398
+ );
399
+
400
+ return (
401
+ <>
402
+ <div>
403
+ {objectNames.map((name) => (
404
+ <div key={name} style={{
405
+ position: 'relative', display: 'inline-block', marginRight: 8, verticalAlign: 'top',
406
+ width: 80, height: 80, borderRadius: 4, overflow: 'hidden', cursor: 'pointer',
407
+ }}
408
+ onMouseEnter={(e) => {
409
+ const mask = e.currentTarget.querySelector('.oa-field-upload-img-mask') as HTMLElement;
410
+ if (mask) {
411
+ mask.style.opacity = '1';
412
+ mask.style.pointerEvents = 'auto';
413
+ }
414
+ }}
415
+ onMouseLeave={(e) => {
416
+ const mask = e.currentTarget.querySelector('.oa-field-upload-img-mask') as HTMLElement;
417
+ if (mask) {
418
+ mask.style.opacity = '0';
419
+ mask.style.pointerEvents = 'none';
420
+ }
421
+ }}
422
+ >
423
+ <img
424
+ src={UrlUtils.contextPath(`/file/${name}?thumb=1`)}
425
+ width={80}
426
+ height={80}
427
+ style={{objectFit: 'cover', borderRadius: 4, display: 'block'}}
428
+ alt={name}
429
+ />
430
+ <div style={{
431
+ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
432
+ background: 'rgba(0,0,0,0.45)', opacity: 0, transition: 'opacity .3s', borderRadius: 4,
433
+ pointerEvents: 'none',
434
+ }} className="oa-field-upload-img-mask">
435
+ <Space size={12}>
436
+ <EyeOutlined
437
+ style={{color: '#fff', fontSize: 18, cursor: 'pointer'}}
438
+ onClick={(e) => {
439
+ e.stopPropagation();
440
+ this.setState({fullPreviewUrl: UrlUtils.contextPath(`/file/${name}`)});
441
+ }}
442
+ />
443
+ <DeleteOutlined
444
+ style={{color: '#fff', fontSize: 18, cursor: 'pointer'}}
445
+ onClick={(e) => {
446
+ e.stopPropagation();
447
+ this.removeImage(name);
448
+ }}
449
+ />
450
+ </Space>
451
+ </div>
320
452
  </div>
321
- <Button icon={<ScissorOutlined/>} onClick={startCrop} style={{marginTop: 12}}>裁切</Button>
322
- </>
323
- )}
324
- </Modal>
325
-
326
- <Modal open={!!fullPreviewUrl} title="图片预览" width="70vw" footer={null}
327
- onCancel={() => setFullPreviewUrl(undefined)}>
328
- {fullPreviewUrl && <img src={fullPreviewUrl} style={{maxWidth: '100%'}} alt="预览"/>}
329
- </Modal>
330
- </>
331
- );
453
+ ))}
454
+ {objectNames.length < maxCount && (
455
+ <Upload accept={accept} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={false}>
456
+ <Button icon={<PlusOutlined/>}>选择图片</Button>
457
+ </Upload>
458
+ )}
459
+ </div>
460
+
461
+ <Modal
462
+ open={modalOpen}
463
+ title="图片处理"
464
+ width={900}
465
+ centered
466
+ okText="确定"
467
+ cancelText="取消"
468
+ onOk={this.handleConfirm}
469
+ onCancel={this.closeModal}
470
+ confirmLoading={uploading}
471
+ okButtonProps={{disabled: tool === 'crop' || !preview}}
472
+ cancelButtonProps={{disabled: tool === 'crop'}}
473
+ styles={{body: {height: 'calc(100vh - 300px)', minHeight: 400, overflow: 'hidden'}}}
474
+ >
475
+ <div style={{display: 'flex', gap: 16, height: '100%'}}>
476
+ {/* 中央画布 + 底部操作按钮 */}
477
+ <div style={{flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12}}>
478
+ <div style={{
479
+ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
480
+ background: '#f5f5f5', borderRadius: 8, border: '1px dashed #d9d9d9', position: 'relative', overflow: 'hidden',
481
+ }}>
482
+ {tool === 'crop' ? (
483
+ <img key="crop-canvas" ref={this.imgRef} src={preview?.cUrl || originalUrl} style={{maxWidth: '100%', maxHeight: '100%'}} alt="待裁切"/>
484
+ ) : canvasImg?.url ? (
485
+ <img src={canvasImg.url} style={{maxWidth: '100%', maxHeight: '100%'}} alt="预览"/>
486
+ ) : (
487
+ <div style={{color: '#999'}}>生成中...</div>
488
+ )}
489
+ </div>
490
+ </div>
491
+
492
+ {/* 右侧属性栏 */}
493
+ <div style={{width: 240, flexShrink: 0, borderLeft: '1px solid #f0f0f0', paddingLeft: 16, display: 'flex', flexDirection: 'column', overflow: 'hidden'}}>
494
+ <div style={{flex: 1, overflowY: 'auto'}}>
495
+ {/* 工具栏(横排) */}
496
+ {tool !== 'crop' && (
497
+ <Space size={8} style={{marginBottom: 12, display: 'flex'}}>
498
+ <Button
499
+ onClick={() => {
500
+ this.setState({tool: 'crop'});
501
+ }}
502
+ >裁切</Button>
503
+ <Button onClick={this.resetImage}>重置</Button>
504
+ </Space>
505
+ )}
506
+ {tool !== 'crop' && <Divider style={{margin: '0 0 12px'}}/>}
507
+ <div style={{fontWeight: 600, marginBottom: 8}}>{tool === 'crop' ? '裁切信息' : '图片信息'}</div>
508
+ {tool === 'crop' ? (
509
+ <>
510
+ <Radio.Group
511
+ value={cropRatio ? formatRatio(cropRatio.width, cropRatio.height) : 'free'}
512
+ onChange={(e) => {
513
+ const found = CROP_RATIOS.find((r) => (r.ratio ? `${r.ratio.width}:${r.ratio.height}` : 'free') === e.target.value);
514
+ this.setState({cropRatio: found ? found.ratio : null});
515
+ }}
516
+ style={{display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12}}
517
+ >
518
+ {CROP_RATIOS.map((r) => (
519
+ <Radio key={r.ratio ? `${r.ratio.width}:${r.ratio.height}` : 'free'} value={r.ratio ? `${r.ratio.width}:${r.ratio.height}` : 'free'}>
520
+ {r.label}{r.desc ? <span style={{color: '#999', fontSize: 12}}>({r.desc})</span> : null}
521
+ </Radio>
522
+ ))}
523
+ </Radio.Group>
524
+ </>
525
+ ) : (
526
+ <>
527
+ {canvasImg && (
528
+ <div style={{color: '#666'}}>
529
+ <div>尺寸:{canvasImg.dims ? `${canvasImg.dims.width} x ${canvasImg.dims.height}` : '--'}</div>
530
+ <div>体积:{formatSize(canvasImg.size)}</div>
531
+ </div>
532
+ )}
533
+
534
+ {/* 压缩处理 */}
535
+ <Divider style={{margin: '12px 0'}}/>
536
+ <div style={{fontWeight: 600, marginBottom: 8}}>压缩处理</div>
537
+ <div style={{marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8}}>
538
+ <span style={{color: '#666', flexShrink: 0}}>最大宽度</span>
539
+ <Select
540
+ value={compressWidth}
541
+ onChange={(v) => this.setState({compressWidth: v})}
542
+ style={{flex: 1}}
543
+ options={[
544
+ {label: '不限', value: 0},
545
+ {label: '400 px(logo)', value: 400},
546
+ {label: '640 px(移动端小图)', value: 640},
547
+ {label: '800 px(商品图)', value: 800},
548
+ {label: '960 px(详情页)', value: 960},
549
+ {label: '1280 px(宽屏)', value: 1280},
550
+ {label: '1920 px(网页)', value: 1920},
551
+ {label: '2560 px(超清)', value: 2560},
552
+ ]}
553
+ />
554
+ </div>
555
+ <div style={{marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8}}>
556
+ <span style={{color: '#666', flexShrink: 0}}>最大体积</span>
557
+ <Select
558
+ value={compressSize}
559
+ onChange={(v) => this.setState({compressSize: v})}
560
+ style={{flex: 1}}
561
+ options={[
562
+ {label: '不限', value: 0},
563
+ {label: '200 KB(秒开)', value: 200 * 1024},
564
+ {label: '500 KB(流畅)', value: 500 * 1024},
565
+ {label: '1 MB(较快)', value: 1024 * 1024},
566
+ {label: '2 MB(较慢)', value: 2 * 1024 * 1024},
567
+ ]}
568
+ />
569
+ </div>
570
+ {needCompress
571
+ ? <Button block danger type="primary" onClick={() => this.applyCompress(compressWidth, compressSize)}>推荐压缩</Button>
572
+ : <Button block disabled>无需压缩</Button>}
573
+ </>
574
+ )}
575
+ </div>
576
+ {tool === 'crop' && (
577
+ <div style={{display: 'flex', gap: 12, marginTop: 'auto', padding: '12px 16px 16px 0'}}>
578
+ <Button style={{flex: 1}} onClick={() => { this.setState({tool: undefined, cropperReady: false}); }}>取消</Button>
579
+ <Button style={{flex: 1}} type="primary" onClick={this.confirmCrop} disabled={!cropperReady}>确认裁切</Button>
580
+ </div>
581
+ )}
582
+ </div>
583
+ </div>
584
+ </Modal>
585
+
586
+ <Modal open={!!fullPreviewUrl} title="图片预览" width="70vw" footer={null}
587
+ onCancel={() => this.setState({fullPreviewUrl: undefined})}>
588
+ {fullPreviewUrl && <img src={fullPreviewUrl} style={{maxWidth: '100%'}} alt="预览"/>}
589
+ </Modal>
590
+ </>
591
+ );
592
+ }
332
593
  }
333
594
 
334
595
  export default FieldUploadImage;