@jiangood/open-admin 3.0.11 → 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.
@@ -0,0 +1,595 @@
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
+ import Compressor from "compressorjs";
5
+ import Cropper from "cropperjs";
6
+ import "cropperjs/dist/cropper.css";
7
+ import {HttpUtils} from "../../utils";
8
+ import {ObjectUtils} from "../../utils";
9
+ import {UrlUtils} from "../../utils";
10
+ import type {FieldProps} from '../types';
11
+
12
+ export interface FieldUploadImageProps extends FieldProps<string> {
13
+ /** 最大上传数量,默认 1 */
14
+ maxCount?: number;
15
+ /** 缩略图最长边,默认 300 */
16
+ thumbWidth?: number;
17
+ /** 是否公开免登录访问,默认 true(private 需登录) */
18
+ isPublic?: boolean;
19
+ /** 接受的文件类型 */
20
+ accept?: string;
21
+ }
22
+
23
+ interface Dims {
24
+ width: number;
25
+ height: number;
26
+ }
27
+
28
+ /** 弹窗内当前主图(原图或处理结果),缩略图在确定上传时生成 */
29
+ interface PreviewResult {
30
+ cUrl: string;
31
+ cFile: File;
32
+ cSize: number;
33
+ cdims: 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;
57
+ }
58
+
59
+ function formatSize(bytes: number): string {
60
+ if (bytes < 1024) return bytes + ' B';
61
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
62
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
63
+ }
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
+
85
+ function readDims(url: string): Promise<Dims> {
86
+ return new Promise((resolve) => {
87
+ const img = new Image();
88
+ img.onload = () => resolve({width: img.naturalWidth, height: img.naturalHeight});
89
+ img.onerror = () => resolve({width: 0, height: 0});
90
+ img.src = url;
91
+ });
92
+ }
93
+
94
+ function compressToFile(source: Blob, options: {maxWidth?: number; maxHeight?: number; width?: number; height?: number; quality?: number}): Promise<File> {
95
+ return new Promise((resolve, reject) => {
96
+ new Compressor(source, {
97
+ maxWidth: options.maxWidth,
98
+ maxHeight: options.maxHeight,
99
+ width: options.width,
100
+ height: options.height,
101
+ quality: options.quality,
102
+ success(result) {
103
+ const file = result instanceof File ? result : new File([result], 'image.jpg', {type: result.type || 'image/jpeg'});
104
+ resolve(file);
105
+ },
106
+ error: reject,
107
+ });
108
+ });
109
+ }
110
+
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;
133
+ }
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
+ }
247
+
248
+ /**
249
+ * 处理源文件为当前主图(不压缩,仅加载画布预览),用于裁切/手动处理后更新画布
250
+ */
251
+ private regenerate = async (file: File) => {
252
+ const cUrl = URL.createObjectURL(file);
253
+ const cdims = await readDims(cUrl);
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) {
303
+ message.warning('已达到最大上传数量');
304
+ return Upload.LIST_IGNORE;
305
+ }
306
+ if (!file.type.startsWith('image/')) {
307
+ message.error('请选择图片文件');
308
+ return Upload.LIST_IGNORE;
309
+ }
310
+
311
+ this.selectedFileRef = file;
312
+ const url = URL.createObjectURL(file);
313
+ this.setState({originalUrl: url, originalSize: file.size, tool: undefined, modalOpen: true});
314
+
315
+ try {
316
+ // EXIF 方向感知地读取原始尺寸
317
+ const bitmap = await createImageBitmap(file, {imageOrientation: 'from-image'});
318
+ const dims = {width: bitmap.width, height: bitmap.height};
319
+ bitmap.close();
320
+ this.setState({originalDims: dims});
321
+ // 默认展示原图,不做压缩处理,用户可点击「压缩」按钮手动压缩
322
+ await this.loadOriginalPreview(file, url, dims);
323
+ } catch (e) {
324
+ message.error('读取图片失败');
325
+ this.closeModal();
326
+ return;
327
+ }
328
+
329
+ return Upload.LIST_IGNORE;
330
+ };
331
+
332
+ private confirmCrop = () => {
333
+ const cropper = this.cropperRef;
334
+ const file = this.state.preview?.cFile || this.selectedFileRef;
335
+ if (!cropper || !file) return;
336
+ const canvas = cropper.getCroppedCanvas();
337
+ const mime = file.type || 'image/jpeg';
338
+ canvas.toBlob(async (blob) => {
339
+ this.destroyCropper();
340
+ this.setState({tool: undefined});
341
+ if (blob) {
342
+ const croppedFile = new File([blob], 'cropped.jpg', {type: mime});
343
+ await this.regenerate(croppedFile);
344
+ }
345
+ }, mime);
346
+ };
347
+
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);
355
+ }
356
+ };
357
+
358
+ private handleConfirm = async () => {
359
+ if (!this.state.preview) return;
360
+ this.setState({uploading: true});
361
+ try {
362
+ // 确定时才生成缩略图
363
+ const tFile = await compressToFile(this.state.preview.cFile, {maxWidth: this.state.thumbWidth, maxHeight: this.state.thumbWidth});
364
+ const fd = new FormData();
365
+ fd.append('file', this.state.preview.cFile);
366
+ fd.append('thumb', tFile);
367
+ fd.append('isPublic', String(this.state.isPublic));
368
+ const rs = await HttpUtils.post('admin/sysFile/uploadImage', fd, null, {headers: {'Content-Type': 'multipart/form-data'}});
369
+ const newNames = [...this.state.objectNames, rs.objectName];
370
+ this.setState({objectNames: newNames});
371
+ this.props.onChange?.(newNames.join(','));
372
+ this.closeModal();
373
+ } catch (e) {
374
+ message.error(HttpUtils.extractErrorMessage(e));
375
+ } finally {
376
+ this.setState({uploading: false});
377
+ }
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>
452
+ </div>
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
+ }
593
+ }
594
+
595
+ export default FieldUploadImage;
@@ -16,4 +16,5 @@ export * from './FieldSysOrgTreeSelect'
16
16
  export * from './FieldSysOrgTree'
17
17
  export * from './FieldPercent'
18
18
  export * from './FieldUploadFile';
19
+ export * from './FieldUploadImage';
19
20
  export * from '../biz/OrgTree';