Commit 8e410fc6401847d8e5545468b5ce6fd7ce9fc5cc
1 parent
de12babd
feat: add CropperAvatar component
Showing
19 changed files
with
550 additions
and
121 deletions
CHANGELOG.zh_CN.md
package.json
@@ -37,7 +37,7 @@ | @@ -37,7 +37,7 @@ | ||
37 | "@logicflow/extension": "^0.4.13", | 37 | "@logicflow/extension": "^0.4.13", |
38 | "@vueuse/core": "^5.0.2", | 38 | "@vueuse/core": "^5.0.2", |
39 | "@zxcvbn-ts/core": "^0.3.0", | 39 | "@zxcvbn-ts/core": "^0.3.0", |
40 | - "ant-design-vue": "2.1.2", | 40 | + "ant-design-vue": "2.1.6", |
41 | "axios": "^0.21.1", | 41 | "axios": "^0.21.1", |
42 | "codemirror": "^5.61.1", | 42 | "codemirror": "^5.61.1", |
43 | "cropperjs": "^1.5.11", | 43 | "cropperjs": "^1.5.11", |
src/components/Application/src/search/AppSearchModal.vue
@@ -4,7 +4,7 @@ | @@ -4,7 +4,7 @@ | ||
4 | <div :class="getClass" @click.stop v-if="visible"> | 4 | <div :class="getClass" @click.stop v-if="visible"> |
5 | <div :class="`${prefixCls}-content`" v-click-outside="handleClose"> | 5 | <div :class="`${prefixCls}-content`" v-click-outside="handleClose"> |
6 | <div :class="`${prefixCls}-input__wrapper`"> | 6 | <div :class="`${prefixCls}-input__wrapper`"> |
7 | - <Input | 7 | + <a-input |
8 | :class="`${prefixCls}-input`" | 8 | :class="`${prefixCls}-input`" |
9 | :placeholder="t('common.searchText')" | 9 | :placeholder="t('common.searchText')" |
10 | ref="inputRef" | 10 | ref="inputRef" |
@@ -14,7 +14,7 @@ | @@ -14,7 +14,7 @@ | ||
14 | <template #prefix> | 14 | <template #prefix> |
15 | <SearchOutlined /> | 15 | <SearchOutlined /> |
16 | </template> | 16 | </template> |
17 | - </Input> | 17 | + </a-input> |
18 | <span :class="`${prefixCls}-cancel`" @click="handleClose"> | 18 | <span :class="`${prefixCls}-cancel`" @click="handleClose"> |
19 | {{ t('common.cancelText') }} | 19 | {{ t('common.cancelText') }} |
20 | </span> | 20 | </span> |
@@ -59,7 +59,6 @@ | @@ -59,7 +59,6 @@ | ||
59 | <script lang="ts"> | 59 | <script lang="ts"> |
60 | import { defineComponent, computed, unref, ref, watch, nextTick } from 'vue'; | 60 | import { defineComponent, computed, unref, ref, watch, nextTick } from 'vue'; |
61 | import { SearchOutlined } from '@ant-design/icons-vue'; | 61 | import { SearchOutlined } from '@ant-design/icons-vue'; |
62 | - import { Input } from 'ant-design-vue'; | ||
63 | import AppSearchFooter from './AppSearchFooter.vue'; | 62 | import AppSearchFooter from './AppSearchFooter.vue'; |
64 | import Icon from '/@/components/Icon'; | 63 | import Icon from '/@/components/Icon'; |
65 | import clickOutside from '/@/directives/clickOutside'; | 64 | import clickOutside from '/@/directives/clickOutside'; |
@@ -75,7 +74,7 @@ | @@ -75,7 +74,7 @@ | ||
75 | 74 | ||
76 | export default defineComponent({ | 75 | export default defineComponent({ |
77 | name: 'AppSearchModal', | 76 | name: 'AppSearchModal', |
78 | - components: { Icon, SearchOutlined, AppSearchFooter, Input }, | 77 | + components: { Icon, SearchOutlined, AppSearchFooter }, |
79 | directives: { | 78 | directives: { |
80 | clickOutside, | 79 | clickOutside, |
81 | }, | 80 | }, |
src/components/CountDown/src/CountdownInput.vue
1 | <template> | 1 | <template> |
2 | - <AInput v-bind="$attrs" :class="prefixCls" :size="size" :value="state"> | 2 | + <a-input v-bind="$attrs" :class="prefixCls" :size="size" :value="state"> |
3 | <template #addonAfter> | 3 | <template #addonAfter> |
4 | <CountButton :size="size" :count="count" :value="state" :beforeStartFunc="sendCodeApi" /> | 4 | <CountButton :size="size" :count="count" :value="state" :beforeStartFunc="sendCodeApi" /> |
5 | </template> | 5 | </template> |
6 | - </AInput> | 6 | + </a-input> |
7 | </template> | 7 | </template> |
8 | <script lang="ts"> | 8 | <script lang="ts"> |
9 | import { defineComponent, PropType } from 'vue'; | 9 | import { defineComponent, PropType } from 'vue'; |
10 | - import { Input } from 'ant-design-vue'; | ||
11 | import CountButton from './CountButton.vue'; | 10 | import CountButton from './CountButton.vue'; |
12 | import { useDesign } from '/@/hooks/web/useDesign'; | 11 | import { useDesign } from '/@/hooks/web/useDesign'; |
13 | import { useRuleFormItem } from '/@/hooks/component/useFormItem'; | 12 | import { useRuleFormItem } from '/@/hooks/component/useFormItem'; |
@@ -24,7 +23,7 @@ | @@ -24,7 +23,7 @@ | ||
24 | 23 | ||
25 | export default defineComponent({ | 24 | export default defineComponent({ |
26 | name: 'CountDownInput', | 25 | name: 'CountDownInput', |
27 | - components: { [Input.name]: Input, CountButton }, | 26 | + components: { CountButton }, |
28 | inheritAttrs: false, | 27 | inheritAttrs: false, |
29 | props, | 28 | props, |
30 | setup(props) { | 29 | setup(props) { |
src/components/Cropper/index.ts
1 | -import type Cropper from 'cropperjs'; | 1 | +import { withInstall } from '/@/utils'; |
2 | +import cropperImage from './src/Cropper.vue'; | ||
3 | +import avatarCropper from './src/CropperAvatar.vue'; | ||
2 | 4 | ||
3 | -export type { Cropper }; | ||
4 | -export { default as CropperImage } from './src/Cropper.vue'; | 5 | +export * from './src/typing'; |
6 | +export const CropperImage = withInstall(cropperImage); | ||
7 | +export const CropperAvatar = withInstall(avatarCropper); |
src/components/Cropper/src/AvatarCropper.vue deleted
100644 → 0
1 | -<template> | ||
2 | - <div :class="$attrs.class" :style="$attrs.style"> </div> | ||
3 | -</template> | ||
4 | -<script lang="ts"> | ||
5 | - // TODO | ||
6 | - import { defineComponent } from 'vue'; | ||
7 | - | ||
8 | - export default defineComponent({ | ||
9 | - name: 'AvatarCropper', | ||
10 | - props: {}, | ||
11 | - setup() { | ||
12 | - return {}; | ||
13 | - }, | ||
14 | - }); | ||
15 | -</script> |
src/components/Cropper/src/CopperModal.vue
0 → 100644
1 | +<template> | ||
2 | + <BasicModal | ||
3 | + v-bind="$attrs" | ||
4 | + @register="register" | ||
5 | + :title="t('component.cropper.modalTitle')" | ||
6 | + width="800px" | ||
7 | + :canFullscreen="false" | ||
8 | + @ok="handleOk" | ||
9 | + :okText="t('component.cropper.okText')" | ||
10 | + > | ||
11 | + <div :class="prefixCls"> | ||
12 | + <div :class="`${prefixCls}-left`"> | ||
13 | + <div :class="`${prefixCls}-cropper`"> | ||
14 | + <CropperImage | ||
15 | + v-if="src" | ||
16 | + :src="src" | ||
17 | + height="300px" | ||
18 | + :circled="circled" | ||
19 | + @cropend="handleCropend" | ||
20 | + @ready="handleReady" | ||
21 | + /> | ||
22 | + </div> | ||
23 | + | ||
24 | + <div :class="`${prefixCls}-toolbar`"> | ||
25 | + <Upload :fileList="[]" accept="image/*" :beforeUpload="handleBeforeUpload"> | ||
26 | + <a-button size="small" preIcon="ant-design:upload-outlined" type="primary" /> | ||
27 | + </Upload> | ||
28 | + <Space> | ||
29 | + <a-button | ||
30 | + type="primary" | ||
31 | + preIcon="ant-design:reload-outlined" | ||
32 | + size="small" | ||
33 | + @click="handlerToolbar('reset')" | ||
34 | + /> | ||
35 | + <a-button | ||
36 | + type="primary" | ||
37 | + preIcon="ant-design:rotate-left-outlined" | ||
38 | + size="small" | ||
39 | + @click="handlerToolbar('rotate', -45)" | ||
40 | + /> | ||
41 | + <a-button | ||
42 | + type="primary" | ||
43 | + preIcon="ant-design:rotate-right-outlined" | ||
44 | + size="small" | ||
45 | + @click="handlerToolbar('rotate', 45)" | ||
46 | + /> | ||
47 | + <a-button | ||
48 | + type="primary" | ||
49 | + preIcon="vaadin:arrows-long-h" | ||
50 | + size="small" | ||
51 | + @click="handlerToolbar('scaleX')" | ||
52 | + /> | ||
53 | + <a-button | ||
54 | + type="primary" | ||
55 | + preIcon="vaadin:arrows-long-v" | ||
56 | + size="small" | ||
57 | + @click="handlerToolbar('scaleY')" | ||
58 | + /> | ||
59 | + <a-button | ||
60 | + type="primary" | ||
61 | + preIcon="ant-design:zoom-in-outlined" | ||
62 | + size="small" | ||
63 | + @click="handlerToolbar('zoom', 0.1)" | ||
64 | + /> | ||
65 | + <a-button | ||
66 | + type="primary" | ||
67 | + preIcon="ant-design:zoom-out-outlined" | ||
68 | + size="small" | ||
69 | + @click="handlerToolbar('zoom', -0.1)" | ||
70 | + /> | ||
71 | + </Space> | ||
72 | + </div> | ||
73 | + </div> | ||
74 | + <div :class="`${prefixCls}-right`"> | ||
75 | + <div :class="`${prefixCls}-preview`"> | ||
76 | + <img :src="previewSource" v-if="previewSource" /> | ||
77 | + </div> | ||
78 | + <template v-if="previewSource"> | ||
79 | + <div :class="`${prefixCls}-group`"> | ||
80 | + <Avatar :src="previewSource" size="large" /> | ||
81 | + <Avatar :src="previewSource" :size="48" /> | ||
82 | + <Avatar :src="previewSource" :size="64" /> | ||
83 | + <Avatar :src="previewSource" :size="80" /> | ||
84 | + </div> | ||
85 | + </template> | ||
86 | + </div> | ||
87 | + </div> | ||
88 | + </BasicModal> | ||
89 | +</template> | ||
90 | +<script lang="ts"> | ||
91 | + import type { CropendResult, Cropper } from './typing'; | ||
92 | + | ||
93 | + import { defineComponent, ref } from 'vue'; | ||
94 | + import CropperImage from './Cropper.vue'; | ||
95 | + import { Space, Upload, Avatar } from 'ant-design-vue'; | ||
96 | + import { useDesign } from '/@/hooks/web/useDesign'; | ||
97 | + import { BasicModal, useModalInner } from '/@/components/Modal'; | ||
98 | + import { dataURLtoBlob } from '/@/utils/file/base64Conver'; | ||
99 | + import { isFunction } from '/@/utils/is'; | ||
100 | + import { useI18n } from '/@/hooks/web/useI18n'; | ||
101 | + | ||
102 | + const props = { | ||
103 | + circled: { type: Boolean, default: true }, | ||
104 | + uploadApi: { | ||
105 | + type: Function as PropType<({ file: Blob, name: stirng, filename: string }) => Promise<any>>, | ||
106 | + }, | ||
107 | + }; | ||
108 | + | ||
109 | + export default defineComponent({ | ||
110 | + name: 'CropperAvatar', | ||
111 | + components: { BasicModal, Space, CropperImage, Upload, Avatar }, | ||
112 | + props, | ||
113 | + emits: ['uploadSuccess', 'register'], | ||
114 | + setup(props, { emit }) { | ||
115 | + let filename = ''; | ||
116 | + const src = ref(''); | ||
117 | + const previewSource = ref(''); | ||
118 | + const cropper = ref<Cropper>(); | ||
119 | + let scaleX = 1; | ||
120 | + let scaleY = 1; | ||
121 | + | ||
122 | + const { prefixCls } = useDesign('cropper-am'); | ||
123 | + const [register, { closeModal, setModalProps }] = useModalInner(); | ||
124 | + const { t } = useI18n(); | ||
125 | + | ||
126 | + // Block upload | ||
127 | + function handleBeforeUpload(file: File) { | ||
128 | + const reader = new FileReader(); | ||
129 | + reader.readAsDataURL(file); | ||
130 | + src.value = ''; | ||
131 | + previewSource.value = ''; | ||
132 | + reader.onload = function (e) { | ||
133 | + src.value = (e.target?.result as string) ?? ''; | ||
134 | + filename = file.name; | ||
135 | + }; | ||
136 | + return false; | ||
137 | + } | ||
138 | + | ||
139 | + function handleCropend({ imgBase64 }: CropendResult) { | ||
140 | + previewSource.value = imgBase64; | ||
141 | + } | ||
142 | + | ||
143 | + function handleReady(cropperInstance: Cropper) { | ||
144 | + cropper.value = cropperInstance; | ||
145 | + } | ||
146 | + | ||
147 | + function handlerToolbar(event: string, arg?: number) { | ||
148 | + if (event === 'scaleX') { | ||
149 | + scaleX = arg = scaleX === -1 ? 1 : -1; | ||
150 | + } | ||
151 | + if (event === 'scaleY') { | ||
152 | + scaleY = arg = scaleY === -1 ? 1 : -1; | ||
153 | + } | ||
154 | + cropper?.value?.[event]?.(arg); | ||
155 | + } | ||
156 | + | ||
157 | + async function handleOk() { | ||
158 | + const uploadApi = props.uploadApi; | ||
159 | + if (uploadApi && isFunction(uploadApi)) { | ||
160 | + const blob = dataURLtoBlob(previewSource.value); | ||
161 | + try { | ||
162 | + setModalProps({ confirmLoading: true }); | ||
163 | + const result = await uploadApi({ name: 'file', file: blob, filename }); | ||
164 | + emit('uploadSuccess', { source: previewSource.value, data: result.data }); | ||
165 | + closeModal(); | ||
166 | + } finally { | ||
167 | + setModalProps({ confirmLoading: false }); | ||
168 | + } | ||
169 | + } | ||
170 | + } | ||
171 | + | ||
172 | + return { | ||
173 | + t, | ||
174 | + prefixCls, | ||
175 | + src, | ||
176 | + register, | ||
177 | + previewSource, | ||
178 | + handleBeforeUpload, | ||
179 | + handleCropend, | ||
180 | + handleReady, | ||
181 | + handlerToolbar, | ||
182 | + handleOk, | ||
183 | + }; | ||
184 | + }, | ||
185 | + }); | ||
186 | +</script> | ||
187 | + | ||
188 | +<style lang="less"> | ||
189 | + @prefix-cls: ~'@{namespace}-cropper-am'; | ||
190 | + | ||
191 | + .@{prefix-cls} { | ||
192 | + display: flex; | ||
193 | + | ||
194 | + &-left, | ||
195 | + &-right { | ||
196 | + height: 340px; | ||
197 | + } | ||
198 | + | ||
199 | + &-left { | ||
200 | + width: 55%; | ||
201 | + } | ||
202 | + | ||
203 | + &-right { | ||
204 | + width: 45%; | ||
205 | + } | ||
206 | + | ||
207 | + &-cropper { | ||
208 | + height: 300px; | ||
209 | + background: #eee; | ||
210 | + background-image: linear-gradient( | ||
211 | + 45deg, | ||
212 | + rgba(0, 0, 0, 0.25) 25%, | ||
213 | + transparent 0, | ||
214 | + transparent 75%, | ||
215 | + rgba(0, 0, 0, 0.25) 0 | ||
216 | + ), | ||
217 | + linear-gradient( | ||
218 | + 45deg, | ||
219 | + rgba(0, 0, 0, 0.25) 25%, | ||
220 | + transparent 0, | ||
221 | + transparent 75%, | ||
222 | + rgba(0, 0, 0, 0.25) 0 | ||
223 | + ); | ||
224 | + background-position: 0 0, 12px 12px; | ||
225 | + background-size: 24px 24px; | ||
226 | + } | ||
227 | + | ||
228 | + &-toolbar { | ||
229 | + display: flex; | ||
230 | + justify-content: space-between; | ||
231 | + align-items: center; | ||
232 | + margin-top: 10px; | ||
233 | + } | ||
234 | + | ||
235 | + &-preview { | ||
236 | + width: 220px; | ||
237 | + height: 220px; | ||
238 | + margin: 0 auto; | ||
239 | + overflow: hidden; | ||
240 | + border: 1px solid @border-color-base; | ||
241 | + border-radius: 50%; | ||
242 | + | ||
243 | + img { | ||
244 | + width: 100%; | ||
245 | + height: 100%; | ||
246 | + } | ||
247 | + } | ||
248 | + | ||
249 | + &-group { | ||
250 | + display: flex; | ||
251 | + padding-top: 8px; | ||
252 | + margin-top: 8px; | ||
253 | + border-top: 1px solid @border-color-base; | ||
254 | + justify-content: space-around; | ||
255 | + align-items: center; | ||
256 | + } | ||
257 | + } | ||
258 | +</style> |
src/components/Cropper/src/Cropper.vue
1 | <template> | 1 | <template> |
2 | - <div :class="$attrs.class" :style="getWrapperStyle"> | 2 | + <div :class="getClass" :style="getWrapperStyle"> |
3 | <img | 3 | <img |
4 | v-show="isReady" | 4 | v-show="isReady" |
5 | ref="imgElRef" | 5 | ref="imgElRef" |
@@ -12,16 +12,16 @@ | @@ -12,16 +12,16 @@ | ||
12 | </template> | 12 | </template> |
13 | <script lang="ts"> | 13 | <script lang="ts"> |
14 | import type { CSSProperties } from 'vue'; | 14 | import type { CSSProperties } from 'vue'; |
15 | - | ||
16 | import { defineComponent, onMounted, ref, unref, computed } from 'vue'; | 15 | import { defineComponent, onMounted, ref, unref, computed } from 'vue'; |
17 | - | ||
18 | import Cropper from 'cropperjs'; | 16 | import Cropper from 'cropperjs'; |
19 | import 'cropperjs/dist/cropper.css'; | 17 | import 'cropperjs/dist/cropper.css'; |
18 | + import { useDesign } from '/@/hooks/web/useDesign'; | ||
19 | + import { useThrottleFn } from '@vueuse/shared'; | ||
20 | 20 | ||
21 | type Options = Cropper.Options; | 21 | type Options = Cropper.Options; |
22 | 22 | ||
23 | const defaultOptions: Options = { | 23 | const defaultOptions: Options = { |
24 | - aspectRatio: 16 / 9, | 24 | + aspectRatio: 1, |
25 | zoomable: true, | 25 | zoomable: true, |
26 | zoomOnTouch: true, | 26 | zoomOnTouch: true, |
27 | zoomOnWheel: true, | 27 | zoomOnWheel: true, |
@@ -43,40 +43,33 @@ | @@ -43,40 +43,33 @@ | ||
43 | rotatable: true, | 43 | rotatable: true, |
44 | }; | 44 | }; |
45 | 45 | ||
46 | - export default defineComponent({ | ||
47 | - name: 'CropperImage', | ||
48 | - props: { | ||
49 | - src: { | ||
50 | - type: String, | ||
51 | - required: true, | ||
52 | - }, | ||
53 | - alt: { | ||
54 | - type: String, | ||
55 | - }, | ||
56 | - height: { | ||
57 | - type: [String, Number], | ||
58 | - default: '360px', | ||
59 | - }, | ||
60 | - crossorigin: { | ||
61 | - type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>, | ||
62 | - default: undefined, | ||
63 | - }, | ||
64 | - imageStyle: { | ||
65 | - type: Object as PropType<CSSProperties>, | ||
66 | - default: () => ({}), | ||
67 | - }, | ||
68 | - options: { | ||
69 | - type: Object as PropType<Options>, | ||
70 | - default: () => ({}), | ||
71 | - }, | 46 | + const props = { |
47 | + src: { type: String, required: true }, | ||
48 | + alt: { type: String }, | ||
49 | + circled: { type: Boolean, default: false }, | ||
50 | + | ||
51 | + realTimePreview: { type: Boolean, default: true }, | ||
52 | + height: { type: [String, Number], default: '360px' }, | ||
53 | + crossorigin: { | ||
54 | + type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>, | ||
55 | + default: undefined, | ||
72 | }, | 56 | }, |
73 | - emits: ['cropperedInfo'], | ||
74 | - setup(props, ctx) { | ||
75 | - const imgElRef = ref<ElRef<HTMLImageElement>>(null); | ||
76 | - const cropper: any = ref<Nullable<Cropper>>(null); | 57 | + imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) }, |
58 | + options: { type: Object as PropType<Options>, default: () => ({}) }, | ||
59 | + }; | ||
77 | 60 | ||
61 | + export default defineComponent({ | ||
62 | + name: 'CropperImage', | ||
63 | + props, | ||
64 | + emits: ['cropend', 'ready', 'cropendError'], | ||
65 | + setup(props, { attrs, emit }) { | ||
66 | + const imgElRef = ref<ElRef<HTMLImageElement>>(); | ||
67 | + const cropper = ref<Nullable<Cropper>>(); | ||
78 | const isReady = ref(false); | 68 | const isReady = ref(false); |
79 | 69 | ||
70 | + const { prefixCls } = useDesign('cropper-image'); | ||
71 | + const throttleRealTimeCroppered = useThrottleFn(realTimeCroppered, 80); | ||
72 | + | ||
80 | const getImageStyle = computed((): CSSProperties => { | 73 | const getImageStyle = computed((): CSSProperties => { |
81 | return { | 74 | return { |
82 | height: props.height, | 75 | height: props.height, |
@@ -85,11 +78,22 @@ | @@ -85,11 +78,22 @@ | ||
85 | }; | 78 | }; |
86 | }); | 79 | }); |
87 | 80 | ||
81 | + const getClass = computed(() => { | ||
82 | + return [ | ||
83 | + prefixCls, | ||
84 | + attrs.class, | ||
85 | + { | ||
86 | + [`${prefixCls}--circled`]: props.circled, | ||
87 | + }, | ||
88 | + ]; | ||
89 | + }); | ||
90 | + | ||
88 | const getWrapperStyle = computed((): CSSProperties => { | 91 | const getWrapperStyle = computed((): CSSProperties => { |
89 | - const { height } = props; | ||
90 | - return { height: `${height}`.replace(/px/, '') + 'px' }; | 92 | + return { height: `${props.height}`.replace(/px/, '') + 'px' }; |
91 | }); | 93 | }); |
92 | 94 | ||
95 | + onMounted(init); | ||
96 | + | ||
93 | async function init() { | 97 | async function init() { |
94 | const imgEl = unref(imgElRef); | 98 | const imgEl = unref(imgElRef); |
95 | if (!imgEl) { | 99 | if (!imgEl) { |
@@ -99,29 +103,83 @@ | @@ -99,29 +103,83 @@ | ||
99 | ...defaultOptions, | 103 | ...defaultOptions, |
100 | ready: () => { | 104 | ready: () => { |
101 | isReady.value = true; | 105 | isReady.value = true; |
106 | + realTimeCroppered(); | ||
107 | + emit('ready', cropper.value); | ||
108 | + }, | ||
109 | + crop() { | ||
110 | + throttleRealTimeCroppered(); | ||
111 | + }, | ||
112 | + zoom() { | ||
113 | + throttleRealTimeCroppered(); | ||
114 | + }, | ||
115 | + cropmove() { | ||
116 | + throttleRealTimeCroppered(); | ||
102 | }, | 117 | }, |
103 | ...props.options, | 118 | ...props.options, |
104 | }); | 119 | }); |
105 | } | 120 | } |
106 | 121 | ||
122 | + // Real-time display preview | ||
123 | + function realTimeCroppered() { | ||
124 | + props.realTimePreview && croppered(); | ||
125 | + } | ||
126 | + | ||
107 | // event: return base64 and width and height information after cropping | 127 | // event: return base64 and width and height information after cropping |
108 | - const croppered = (): void => { | 128 | + function croppered() { |
129 | + if (!cropper.value) { | ||
130 | + return; | ||
131 | + } | ||
109 | let imgInfo = cropper.value.getData(); | 132 | let imgInfo = cropper.value.getData(); |
110 | - cropper.value.getCroppedCanvas().toBlob((blob) => { | 133 | + const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas(); |
134 | + canvas.toBlob((blob) => { | ||
135 | + if (!blob) { | ||
136 | + return; | ||
137 | + } | ||
111 | let fileReader: FileReader = new FileReader(); | 138 | let fileReader: FileReader = new FileReader(); |
139 | + fileReader.readAsDataURL(blob); | ||
112 | fileReader.onloadend = (e) => { | 140 | fileReader.onloadend = (e) => { |
113 | - ctx.emit('cropperedInfo', { | 141 | + emit('cropend', { |
114 | imgBase64: e.target?.result ?? '', | 142 | imgBase64: e.target?.result ?? '', |
115 | imgInfo, | 143 | imgInfo, |
116 | }); | 144 | }); |
117 | }; | 145 | }; |
118 | - fileReader.readAsDataURL(blob); | ||
119 | - }, 'image/jpeg'); | ||
120 | - }; | 146 | + fileReader.onerror = () => { |
147 | + emit('cropendError'); | ||
148 | + }; | ||
149 | + }, 'image/png'); | ||
150 | + } | ||
121 | 151 | ||
122 | - onMounted(init); | 152 | + // Get a circular picture canvas |
153 | + function getRoundedCanvas() { | ||
154 | + const sourceCanvas = cropper.value!.getCroppedCanvas(); | ||
155 | + const canvas = document.createElement('canvas'); | ||
156 | + const context = canvas.getContext('2d')!; | ||
157 | + const width = sourceCanvas.width; | ||
158 | + const height = sourceCanvas.height; | ||
159 | + canvas.width = width; | ||
160 | + canvas.height = height; | ||
161 | + context.imageSmoothingEnabled = true; | ||
162 | + context.drawImage(sourceCanvas, 0, 0, width, height); | ||
163 | + context.globalCompositeOperation = 'destination-in'; | ||
164 | + context.beginPath(); | ||
165 | + context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true); | ||
166 | + context.fill(); | ||
167 | + return canvas; | ||
168 | + } | ||
123 | 169 | ||
124 | - return { imgElRef, getWrapperStyle, getImageStyle, isReady, croppered }; | 170 | + return { getClass, imgElRef, getWrapperStyle, getImageStyle, isReady, croppered }; |
125 | }, | 171 | }, |
126 | }); | 172 | }); |
127 | </script> | 173 | </script> |
174 | +<style lang="less"> | ||
175 | + @prefix-cls: ~'@{namespace}-cropper-image'; | ||
176 | + | ||
177 | + .@{prefix-cls} { | ||
178 | + &--circled { | ||
179 | + .cropper-view-box, | ||
180 | + .cropper-face { | ||
181 | + border-radius: 50%; | ||
182 | + } | ||
183 | + } | ||
184 | + } | ||
185 | +</style> |
src/components/Cropper/src/CropperAvatar.vue
0 → 100644
1 | +<template> | ||
2 | + <div :class="getClass" :style="getStyle"> | ||
3 | + <div :class="`${prefixCls}-image-wrapper`" :style="getImageWrapperStyle" @click="openModal"> | ||
4 | + <img :src="sourceValue" v-if="sourceValue" alt="avatar" /> | ||
5 | + </div> | ||
6 | + <a-button :class="`${prefixCls}-upload-btn`" @click="openModal"> | ||
7 | + {{ t('component.cropper.selectImage') }} | ||
8 | + </a-button> | ||
9 | + <CopperModal @register="register" @uploadSuccess="handleUploadSuccess" :uploadApi="uploadApi" /> | ||
10 | + </div> | ||
11 | +</template> | ||
12 | +<script lang="ts"> | ||
13 | + import { defineComponent, computed, CSSProperties, unref, ref } from 'vue'; | ||
14 | + import CopperModal from './CopperModal.vue'; | ||
15 | + import { useDesign } from '/@/hooks/web/useDesign'; | ||
16 | + import { useModal } from '/@/components/Modal'; | ||
17 | + import { useMessage } from '/@/hooks/web/useMessage'; | ||
18 | + import { useI18n } from '/@/hooks/web/useI18n'; | ||
19 | + | ||
20 | + const props = { | ||
21 | + src: { type: String, required: true }, | ||
22 | + width: { type: [String, Number], default: '200px' }, | ||
23 | + uploadApi: { type: Function as PropType<({ file: Blob, name: stirng }) => Promise<void>> }, | ||
24 | + }; | ||
25 | + | ||
26 | + export default defineComponent({ | ||
27 | + name: 'CropperAvatar', | ||
28 | + components: { CopperModal }, | ||
29 | + props, | ||
30 | + setup(props) { | ||
31 | + const sourceValue = ref(''); | ||
32 | + const { prefixCls } = useDesign('cropper-avatar'); | ||
33 | + const [register, { openModal }] = useModal(); | ||
34 | + const { createMessage } = useMessage(); | ||
35 | + const { t } = useI18n(); | ||
36 | + | ||
37 | + const getClass = computed(() => [prefixCls]); | ||
38 | + | ||
39 | + const getWidth = computed(() => `${props.width}`.replace(/px/, '') + 'px'); | ||
40 | + | ||
41 | + const getStyle = computed((): CSSProperties => ({ width: unref(getWidth) })); | ||
42 | + | ||
43 | + const getImageWrapperStyle = computed( | ||
44 | + (): CSSProperties => ({ width: unref(getWidth), height: unref(getWidth) }) | ||
45 | + ); | ||
46 | + | ||
47 | + function handleUploadSuccess({ source }) { | ||
48 | + sourceValue.value = source; | ||
49 | + createMessage.success(t('component.cropper.uploadSuccess')); | ||
50 | + } | ||
51 | + | ||
52 | + return { | ||
53 | + t, | ||
54 | + prefixCls, | ||
55 | + register, | ||
56 | + openModal, | ||
57 | + sourceValue, | ||
58 | + getClass, | ||
59 | + getImageWrapperStyle, | ||
60 | + getStyle, | ||
61 | + handleUploadSuccess, | ||
62 | + }; | ||
63 | + }, | ||
64 | + }); | ||
65 | +</script> | ||
66 | + | ||
67 | +<style lang="less" scoped> | ||
68 | + @prefix-cls: ~'@{namespace}-cropper-avatar'; | ||
69 | + | ||
70 | + .@{prefix-cls} { | ||
71 | + display: inline-block; | ||
72 | + text-align: center; | ||
73 | + | ||
74 | + &-image-wrapper { | ||
75 | + overflow: hidden; | ||
76 | + cursor: pointer; | ||
77 | + background: @component-background; | ||
78 | + border: 1px solid @border-color-base; | ||
79 | + border-radius: 50%; | ||
80 | + | ||
81 | + img { | ||
82 | + width: 100%; | ||
83 | + } | ||
84 | + } | ||
85 | + | ||
86 | + &-upload-btn { | ||
87 | + margin: 10px auto; | ||
88 | + } | ||
89 | + } | ||
90 | +</style> |
src/components/Cropper/src/typing.ts
0 → 100644
src/locales/lang/en/component.ts
@@ -8,6 +8,12 @@ export default { | @@ -8,6 +8,12 @@ export default { | ||
8 | normalText: 'Get SMS code', | 8 | normalText: 'Get SMS code', |
9 | sendText: 'Reacquire in {0}s', | 9 | sendText: 'Reacquire in {0}s', |
10 | }, | 10 | }, |
11 | + cropper: { | ||
12 | + selectImage: 'Select Image', | ||
13 | + uploadSuccess: 'Uploaded success!', | ||
14 | + modalTitle: 'Avatar upload', | ||
15 | + okText: 'Confirm and upload', | ||
16 | + }, | ||
11 | drawer: { | 17 | drawer: { |
12 | loadingText: 'Loading...', | 18 | loadingText: 'Loading...', |
13 | cancelText: 'Close', | 19 | cancelText: 'Close', |
src/locales/lang/zh_CN/component.ts
@@ -8,6 +8,12 @@ export default { | @@ -8,6 +8,12 @@ export default { | ||
8 | normalText: '获取验证码', | 8 | normalText: '获取验证码', |
9 | sendText: '{0}秒后重新获取', | 9 | sendText: '{0}秒后重新获取', |
10 | }, | 10 | }, |
11 | + cropper: { | ||
12 | + selectImage: '选择图片', | ||
13 | + uploadSuccess: '上传成功', | ||
14 | + modalTitle: '头像上传', | ||
15 | + okText: '确认并上传', | ||
16 | + }, | ||
11 | drawer: { | 17 | drawer: { |
12 | loadingText: '加载中...', | 18 | loadingText: '加载中...', |
13 | cancelText: '关闭', | 19 | cancelText: '关闭', |
src/router/menus/modules/demo/comp.ts
@@ -6,6 +6,7 @@ const menu: MenuModule = { | @@ -6,6 +6,7 @@ const menu: MenuModule = { | ||
6 | menu: { | 6 | menu: { |
7 | name: t('routes.demo.comp.comp'), | 7 | name: t('routes.demo.comp.comp'), |
8 | path: '/comp', | 8 | path: '/comp', |
9 | + tag: { dot: true }, | ||
9 | children: [ | 10 | children: [ |
10 | { | 11 | { |
11 | path: 'basic', | 12 | path: 'basic', |
@@ -124,6 +125,9 @@ const menu: MenuModule = { | @@ -124,6 +125,9 @@ const menu: MenuModule = { | ||
124 | { | 125 | { |
125 | path: 'cropper', | 126 | path: 'cropper', |
126 | name: t('routes.demo.comp.cropperImage'), | 127 | name: t('routes.demo.comp.cropperImage'), |
128 | + tag: { | ||
129 | + content: 'new', | ||
130 | + }, | ||
127 | }, | 131 | }, |
128 | { | 132 | { |
129 | path: 'countTo', | 133 | path: 'countTo', |
@@ -192,9 +196,6 @@ const menu: MenuModule = { | @@ -192,9 +196,6 @@ const menu: MenuModule = { | ||
192 | { | 196 | { |
193 | path: 'json', | 197 | path: 'json', |
194 | name: t('routes.demo.editor.jsonEditor'), | 198 | name: t('routes.demo.editor.jsonEditor'), |
195 | - tag: { | ||
196 | - content: 'new', | ||
197 | - }, | ||
198 | }, | 199 | }, |
199 | { | 200 | { |
200 | path: 'markdown', | 201 | path: 'markdown', |
src/router/menus/modules/demo/feat.ts
@@ -6,9 +6,6 @@ const menu: MenuModule = { | @@ -6,9 +6,6 @@ const menu: MenuModule = { | ||
6 | menu: { | 6 | menu: { |
7 | name: t('routes.demo.feat.feat'), | 7 | name: t('routes.demo.feat.feat'), |
8 | path: '/feat', | 8 | path: '/feat', |
9 | - tag: { | ||
10 | - dot: true, | ||
11 | - }, | ||
12 | children: [ | 9 | children: [ |
13 | { | 10 | { |
14 | path: 'icon', | 11 | path: 'icon', |
@@ -21,9 +18,6 @@ const menu: MenuModule = { | @@ -21,9 +18,6 @@ const menu: MenuModule = { | ||
21 | { | 18 | { |
22 | name: t('routes.demo.feat.sessionTimeout'), | 19 | name: t('routes.demo.feat.sessionTimeout'), |
23 | path: 'session-timeout', | 20 | path: 'session-timeout', |
24 | - tag: { | ||
25 | - content: 'new', | ||
26 | - }, | ||
27 | }, | 21 | }, |
28 | { | 22 | { |
29 | path: 'tabs', | 23 | path: 'tabs', |
src/views/demo/comp/cropper/index.vue
1 | <template> | 1 | <template> |
2 | - <PageWrapper title="图片裁剪示例" contentBackground> | ||
3 | - <div class="container"> | ||
4 | - <div class="cropper-container"> | ||
5 | - <CropperImage | ||
6 | - ref="refCropper" | ||
7 | - src="https://fengyuanchen.github.io/cropperjs/images/picture.jpg" | ||
8 | - @cropperedInfo="cropperedInfo" | ||
9 | - style="width: 40vw" | ||
10 | - /> | 2 | + <PageWrapper title="图片裁剪示例" content="需要开启测试接口服务才能进行上传测试!"> |
3 | + <CollapseContainer title="头像裁剪"> | ||
4 | + <CropperAvatar :src="cropperImg" :uploadApi="uploadApi" /> | ||
5 | + </CollapseContainer> | ||
6 | + | ||
7 | + <CollapseContainer title="矩形裁剪" class="my-4"> | ||
8 | + <div class="container p-4"> | ||
9 | + <div class="cropper-container mr-10"> | ||
10 | + <CropperImage ref="refCropper" :src="img" @cropend="handleCropend" style="width: 40vw" /> | ||
11 | + </div> | ||
12 | + <img :src="cropperImg" class="croppered" v-if="cropperImg" /> | ||
13 | + </div> | ||
14 | + <p v-if="cropperImg">裁剪后图片信息:{{ info }}</p> | ||
15 | + </CollapseContainer> | ||
16 | + | ||
17 | + <CollapseContainer title="圆形裁剪"> | ||
18 | + <div class="container p-4"> | ||
19 | + <div class="cropper-container mr-10"> | ||
20 | + <CropperImage | ||
21 | + ref="refCropper" | ||
22 | + :src="img" | ||
23 | + @cropend="handleCircleCropend" | ||
24 | + style="width: 40vw" | ||
25 | + circled | ||
26 | + /> | ||
27 | + </div> | ||
28 | + <img :src="circleImg" class="croppered" v-if="circleImg" /> | ||
11 | </div> | 29 | </div> |
12 | - <a-button type="primary" @click="onCropper"> 裁剪 </a-button> | ||
13 | - <img :src="cropperImg" class="croppered" v-if="cropperImg" /> | ||
14 | - </div> | ||
15 | - <p v-if="cropperImg">裁剪后图片信息:{{ info }}</p> | 30 | + <p v-if="circleImg">裁剪后图片信息:{{ circleInfo }}</p> |
31 | + </CollapseContainer> | ||
16 | </PageWrapper> | 32 | </PageWrapper> |
17 | </template> | 33 | </template> |
18 | <script lang="ts"> | 34 | <script lang="ts"> |
19 | - import { defineComponent, ref, unref } from 'vue'; | 35 | + import { defineComponent, ref } from 'vue'; |
20 | import { PageWrapper } from '/@/components/Page'; | 36 | import { PageWrapper } from '/@/components/Page'; |
21 | - import { CropperImage } from '/@/components/Cropper'; | 37 | + import { CollapseContainer } from '/@/components/Container/index'; |
38 | + import { CropperImage, CropperAvatar } from '/@/components/Cropper'; | ||
39 | + import { uploadApi } from '/@/api/sys/upload'; | ||
22 | import img from '/@/assets/images/header.jpg'; | 40 | import img from '/@/assets/images/header.jpg'; |
23 | - import { templateRef } from '@vueuse/core'; | ||
24 | 41 | ||
25 | export default defineComponent({ | 42 | export default defineComponent({ |
26 | components: { | 43 | components: { |
27 | PageWrapper, | 44 | PageWrapper, |
28 | CropperImage, | 45 | CropperImage, |
46 | + CollapseContainer, | ||
47 | + CropperAvatar, | ||
29 | }, | 48 | }, |
30 | setup() { | 49 | setup() { |
31 | - let info = ref(''); | ||
32 | - let cropperImg = ref(''); | ||
33 | - const refCropper = templateRef<HTMLElement | null>('refCropper', null); | 50 | + const info = ref(''); |
51 | + const cropperImg = ref(''); | ||
52 | + const circleInfo = ref(''); | ||
53 | + const circleImg = ref(''); | ||
34 | 54 | ||
35 | - const onCropper = (): void => { | ||
36 | - unref(refCropper).croppered(); | ||
37 | - }; | ||
38 | - | ||
39 | - function cropperedInfo({ imgBase64, imgInfo }) { | 55 | + function handleCropend({ imgBase64, imgInfo }) { |
40 | info.value = imgInfo; | 56 | info.value = imgInfo; |
41 | cropperImg.value = imgBase64; | 57 | cropperImg.value = imgBase64; |
42 | } | 58 | } |
43 | 59 | ||
60 | + function handleCircleCropend({ imgBase64, imgInfo }) { | ||
61 | + circleInfo.value = imgInfo; | ||
62 | + circleImg.value = imgBase64; | ||
63 | + } | ||
64 | + | ||
44 | return { | 65 | return { |
45 | img, | 66 | img, |
46 | info, | 67 | info, |
68 | + circleInfo, | ||
47 | cropperImg, | 69 | cropperImg, |
48 | - onCropper, | ||
49 | - cropperedInfo, | 70 | + circleImg, |
71 | + handleCropend, | ||
72 | + handleCircleCropend, | ||
73 | + uploadApi, | ||
50 | }; | 74 | }; |
51 | }, | 75 | }, |
52 | }); | 76 | }); |
src/views/demo/comp/upload/index.vue
@@ -21,7 +21,6 @@ | @@ -21,7 +21,6 @@ | ||
21 | import { BasicForm, FormSchema, useForm } from '/@/components/Form/index'; | 21 | import { BasicForm, FormSchema, useForm } from '/@/components/Form/index'; |
22 | import { PageWrapper } from '/@/components/Page'; | 22 | import { PageWrapper } from '/@/components/Page'; |
23 | import { Alert } from 'ant-design-vue'; | 23 | import { Alert } from 'ant-design-vue'; |
24 | - | ||
25 | import { uploadApi } from '/@/api/sys/upload'; | 24 | import { uploadApi } from '/@/api/sys/upload'; |
26 | 25 | ||
27 | const schemas: FormSchema[] = [ | 26 | const schemas: FormSchema[] = [ |
src/views/demo/feat/copy/index.vue
@@ -14,11 +14,10 @@ | @@ -14,11 +14,10 @@ | ||
14 | import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard'; | 14 | import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard'; |
15 | import { useMessage } from '/@/hooks/web/useMessage'; | 15 | import { useMessage } from '/@/hooks/web/useMessage'; |
16 | import { PageWrapper } from '/@/components/Page'; | 16 | import { PageWrapper } from '/@/components/Page'; |
17 | - import { Input } from 'ant-design-vue'; | ||
18 | 17 | ||
19 | export default defineComponent({ | 18 | export default defineComponent({ |
20 | name: 'Copy', | 19 | name: 'Copy', |
21 | - components: { CollapseContainer, PageWrapper, [Input.name]: Input }, | 20 | + components: { CollapseContainer, PageWrapper }, |
22 | setup() { | 21 | setup() { |
23 | const valueRef = ref(''); | 22 | const valueRef = ref(''); |
24 | const { createMessage } = useMessage(); | 23 | const { createMessage } = useMessage(); |
test/server/service/FileService.ts
@@ -10,8 +10,6 @@ export default class UserService { | @@ -10,8 +10,6 @@ export default class UserService { | ||
10 | let fileReader, fileResource, writeStream; | 10 | let fileReader, fileResource, writeStream; |
11 | 11 | ||
12 | const fileFunc = function (file) { | 12 | const fileFunc = function (file) { |
13 | - console.log(file); | ||
14 | - | ||
15 | fileReader = fs.createReadStream(file.path); | 13 | fileReader = fs.createReadStream(file.path); |
16 | fileResource = filePath + `/${file.name}`; | 14 | fileResource = filePath + `/${file.name}`; |
17 | console.log(fileResource); | 15 | console.log(fileResource); |
yarn.lock
@@ -2188,10 +2188,10 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: | @@ -2188,10 +2188,10 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: | ||
2188 | dependencies: | 2188 | dependencies: |
2189 | color-convert "^2.0.1" | 2189 | color-convert "^2.0.1" |
2190 | 2190 | ||
2191 | -ant-design-vue@2.1.2: | ||
2192 | - version "2.1.2" | ||
2193 | - resolved "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-2.1.2.tgz#2065d7e63199c0c584919458af57b6a0b597f677" | ||
2194 | - integrity sha512-gDG0wauGVt4LE63behrJaIcq4BB+dgs+dpj9jz17IgKr2MPYSEeKetU/x9Kk8d58cGonz4Ulncg7fBZJ7EljsQ== | 2191 | +ant-design-vue@2.1.6: |
2192 | + version "2.1.6" | ||
2193 | + resolved "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-2.1.6.tgz#c51cdc858e1b1b8b569f5435eb487f53a3f1745e" | ||
2194 | + integrity sha512-qICxb6Y4f7QuSuh/jbLhZA9SkUBnP9xYfy/E6yD7+1fg04aAzmRK8oLv8ETuGTrROVdSVeic9v/NS2BXEuuARg== | ||
2195 | dependencies: | 2195 | dependencies: |
2196 | "@ant-design-vue/use" "^0.0.1-0" | 2196 | "@ant-design-vue/use" "^0.0.1-0" |
2197 | "@ant-design/icons-vue" "^6.0.0" | 2197 | "@ant-design/icons-vue" "^6.0.0" |
@@ -2201,7 +2201,7 @@ ant-design-vue@2.1.2: | @@ -2201,7 +2201,7 @@ ant-design-vue@2.1.2: | ||
2201 | async-validator "^3.3.0" | 2201 | async-validator "^3.3.0" |
2202 | dom-align "^1.10.4" | 2202 | dom-align "^1.10.4" |
2203 | dom-scroll-into-view "^2.0.0" | 2203 | dom-scroll-into-view "^2.0.0" |
2204 | - is-mobile "^2.2.1" | 2204 | + lodash "^4.17.21" |
2205 | lodash-es "^4.17.15" | 2205 | lodash-es "^4.17.15" |
2206 | moment "^2.27.0" | 2206 | moment "^2.27.0" |
2207 | omit.js "^2.0.0" | 2207 | omit.js "^2.0.0" |
@@ -6079,11 +6079,6 @@ is-jpg@^2.0.0: | @@ -6079,11 +6079,6 @@ is-jpg@^2.0.0: | ||
6079 | resolved "https://registry.npmjs.org/is-jpg/-/is-jpg-2.0.0.tgz#2e1997fa6e9166eaac0242daae443403e4ef1d97" | 6079 | resolved "https://registry.npmjs.org/is-jpg/-/is-jpg-2.0.0.tgz#2e1997fa6e9166eaac0242daae443403e4ef1d97" |
6080 | integrity sha1-LhmX+m6RZuqsAkLarkQ0A+TvHZc= | 6080 | integrity sha1-LhmX+m6RZuqsAkLarkQ0A+TvHZc= |
6081 | 6081 | ||
6082 | -is-mobile@^2.2.1: | ||
6083 | - version "2.2.2" | ||
6084 | - resolved "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.2.tgz#f6c9c5d50ee01254ce05e739bdd835f1ed4e9954" | ||
6085 | - integrity sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg== | ||
6086 | - | ||
6087 | is-module@^1.0.0: | 6082 | is-module@^1.0.0: |
6088 | version "1.0.0" | 6083 | version "1.0.0" |
6089 | resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" | 6084 | resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" |