@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.
- package/package.json +1 -1
- package/src/framework/components/ProTable/index.tsx +3 -1
- package/src/framework/fields/FieldEditor/index.tsx +56 -10
- package/src/framework/fields/FieldUploadFile/index.tsx +6 -6
- package/src/framework/fields/FieldUploadImage/index.tsx +494 -233
- package/src/framework/views/ViewImage/index.tsx +56 -50
- package/src/layouts/admin/index.less +1 -1
- package/src/pages/article/$code.jsx +20 -6
- package/src/pages/article/article.less +124 -0
- package/src/pages/system/article/index.jsx +12 -22
- package/src/pages/system/file/index.jsx +77 -93
- package/src/pages/system/job/index.jsx +1 -1
|
@@ -1,26 +1,21 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import {
|
|
3
|
-
import {DeleteOutlined,
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
const
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
311
|
+
this.selectedFileRef = file;
|
|
153
312
|
const url = URL.createObjectURL(file);
|
|
154
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
193
|
-
const cropper = cropperRef
|
|
194
|
-
const file = selectedFileRef
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
210
|
-
}
|
|
345
|
+
}, mime);
|
|
346
|
+
};
|
|
211
347
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
}, []);
|
|
356
|
+
};
|
|
219
357
|
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
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',
|
|
227
|
-
fd.append('
|
|
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
|
-
|
|
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
|
-
|
|
376
|
+
this.setState({uploading: false});
|
|
237
377
|
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const newNames = objectNames.filter((n) => n !== name);
|
|
242
|
-
|
|
243
|
-
onChange?.(newNames.join(','));
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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;
|