foucui 1.0.1 → 1.0.2

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.
Files changed (234) hide show
  1. package/dist/components/QiuCanvasKit/QiuCanvasEditor.vue.d.ts +3 -1
  2. package/dist/components/QiuCanvasKit/components/DataModulesPanel.vue.d.ts +1 -0
  3. package/dist/components/QiuTable/index.vue.d.ts +12 -12
  4. package/dist/foucui.css +1 -1
  5. package/dist/foucui.js +7476 -7373
  6. package/dist/foucui.umd.cjs +19 -19
  7. package/package.json +23 -13
  8. package/components/QiuAffix/index.vue +0 -186
  9. package/components/QiuAlert/index.vue +0 -208
  10. package/components/QiuAnchor/context.ts +0 -18
  11. package/components/QiuAnchor/index.vue +0 -180
  12. package/components/QiuAnchorLink/index.vue +0 -67
  13. package/components/QiuApp/index.vue +0 -9
  14. package/components/QiuAutocomplete/index.vue +0 -408
  15. package/components/QiuAvatar/index.vue +0 -140
  16. package/components/QiuBacktop/index.vue +0 -121
  17. package/components/QiuBadge/api/index.ts +0 -389
  18. package/components/QiuBadge/components/BadgeImagePickerDialog.vue +0 -243
  19. package/components/QiuBadge/components/BadgeLibraryManagerDialog.vue +0 -30
  20. package/components/QiuBadge/components/BadgeLibraryPanel.vue +0 -182
  21. package/components/QiuBadge/composables/useTableHostHeight.ts +0 -53
  22. package/components/QiuBadge/index.ts +0 -18
  23. package/components/QiuBadge/shared/BadgeTemplateRenderer.vue +0 -32
  24. package/components/QiuBadge/shared/assetUrl.ts +0 -26
  25. package/components/QiuBadge/shared/badgeTemplate.ts +0 -309
  26. package/components/QiuBadge/utils/chinaAreaData.ts +0 -44
  27. package/components/QiuBadge/utils/hostRegion.ts +0 -22
  28. package/components/QiuBadge/utils/pca-code.json +0 -1
  29. package/components/QiuBadge/utils/pdfFromElement.ts +0 -348
  30. package/components/QiuBadge/views/BadgeLibraryView.vue +0 -32
  31. package/components/QiuBadge/views/BadgeManageView.vue +0 -1364
  32. package/components/QiuBadge/views/BadgeTemplateEditorView.vue +0 -10
  33. package/components/QiuBadge/views/BadgeTemplateListView.vue +0 -229
  34. package/components/QiuBadge/views/DeskLabelTemplateListView.vue +0 -227
  35. package/components/QiuBadge/views/DeskLabelView.vue +0 -683
  36. package/components/QiuBadgeDot/index.vue +0 -87
  37. package/components/QiuButton/index.vue +0 -247
  38. package/components/QiuButtonGroup/index.vue +0 -63
  39. package/components/QiuCanvasKit/QiuCanvasEditor.vue +0 -5503
  40. package/components/QiuCanvasKit/QiuCanvasRenderer.vue +0 -348
  41. package/components/QiuCanvasKit/assetUrl.ts +0 -11
  42. package/components/QiuCanvasKit/components/DataModulesPanel.vue +0 -352
  43. package/components/QiuCanvasKit/export/html.ts +0 -132
  44. package/components/QiuCanvasKit/export/pdf.ts +0 -48
  45. package/components/QiuCanvasKit/export/png.ts +0 -50
  46. package/components/QiuCanvasKit/index.ts +0 -20
  47. package/components/QiuCanvasKit/modules/badgeCompat.ts +0 -84
  48. package/components/QiuCanvasKit/modules/dataRegistry.ts +0 -207
  49. package/components/QiuCanvasKit/modules/merge.ts +0 -71
  50. package/components/QiuCanvasKit/presets/badge.ts +0 -67
  51. package/components/QiuCanvasKit/presets/banner.ts +0 -79
  52. package/components/QiuCanvasKit/types.ts +0 -138
  53. package/components/QiuCard/index.vue +0 -104
  54. package/components/QiuCarousel/context.ts +0 -16
  55. package/components/QiuCarousel/index.vue +0 -343
  56. package/components/QiuCarouselItem/index.vue +0 -47
  57. package/components/QiuCascader/index.vue +0 -879
  58. package/components/QiuCascader/types.ts +0 -48
  59. package/components/QiuCheckbox/QiuCheckboxGroup.vue +0 -78
  60. package/components/QiuCheckbox/index.vue +0 -231
  61. package/components/QiuCheckbox/types.ts +0 -8
  62. package/components/QiuCode/index.vue +0 -64
  63. package/components/QiuCol/col.scss +0 -55
  64. package/components/QiuCol/index.vue +0 -64
  65. package/components/QiuCollapse/context.ts +0 -10
  66. package/components/QiuCollapse/index.vue +0 -70
  67. package/components/QiuCollapseItem/index.vue +0 -167
  68. package/components/QiuColorField/index.vue +0 -296
  69. package/components/QiuColorPicker/README.md +0 -118
  70. package/components/QiuColorPicker/index.vue +0 -60
  71. package/components/QiuColorfulCard/index.vue +0 -101
  72. package/components/QiuCombobox/index.vue +0 -277
  73. package/components/QiuConfigProvider/index.vue +0 -31
  74. package/components/QiuContextMenu/index.vue +0 -209
  75. package/components/QiuDate/index.vue +0 -1134
  76. package/components/QiuDescriptions/context.ts +0 -20
  77. package/components/QiuDescriptions/index.vue +0 -115
  78. package/components/QiuDescriptionsItem/index.vue +0 -103
  79. package/components/QiuDialog/index.vue +0 -828
  80. package/components/QiuDigitalCard/index.vue +0 -159
  81. package/components/QiuDivider/index.vue +0 -185
  82. package/components/QiuDot/index.vue +0 -83
  83. package/components/QiuDrawer/index.vue +0 -350
  84. package/components/QiuDropdown/QiuDropdownItem.vue +0 -74
  85. package/components/QiuDropdown/QiuDropdownMenu.vue +0 -22
  86. package/components/QiuDropdown/context.ts +0 -9
  87. package/components/QiuDropdown/index.vue +0 -189
  88. package/components/QiuEmpty/index.vue +0 -73
  89. package/components/QiuForm/index.vue +0 -172
  90. package/components/QiuForm/types.ts +0 -39
  91. package/components/QiuFormItem/index.vue +0 -276
  92. package/components/QiuFullscreen/index.vue +0 -11
  93. package/components/QiuGrid/index.vue +0 -408
  94. package/components/QiuHoverCard/index.vue +0 -192
  95. package/components/QiuIcon/index.vue +0 -117
  96. package/components/QiuIconPicker/iconify.libraries.ts +0 -182
  97. package/components/QiuIconPicker/icons.catalog.ts +0 -3072
  98. package/components/QiuIconPicker/index.vue +0 -488
  99. package/components/QiuImage/index.vue +0 -177
  100. package/components/QiuInput/index.vue +0 -328
  101. package/components/QiuInputNumber/index.vue +0 -295
  102. package/components/QiuInputOtp/index.vue +0 -190
  103. package/components/QiuKbd/index.vue +0 -57
  104. package/components/QiuLayoutContainer/index.vue +0 -50
  105. package/components/QiuLinkPreview/index.vue +0 -224
  106. package/components/QiuLoading/index.ts +0 -176
  107. package/components/QiuLoading/spinner.vue +0 -820
  108. package/components/QiuLoading/types.ts +0 -44
  109. package/components/QiuMention/index.vue +0 -299
  110. package/components/QiuMenu/QiuMenuRoot.vue +0 -241
  111. package/components/QiuMenu/components/QiuMenuItem.vue +0 -340
  112. package/components/QiuMenu/components/QiuSubMenu.vue +0 -504
  113. package/components/QiuMenu/context.ts +0 -21
  114. package/components/QiuMenu/index.vue +0 -43
  115. package/components/QiuMenuNav/QiuMenuNavItem.vue +0 -100
  116. package/components/QiuMenuNav/QiuMenuNavSub.vue +0 -120
  117. package/components/QiuMenuNav/context.ts +0 -14
  118. package/components/QiuMenuNav/index.vue +0 -148
  119. package/components/QiuMsg/QiuMsgItem.vue +0 -180
  120. package/components/QiuMsg/index.ts +0 -158
  121. package/components/QiuMsg/types.ts +0 -23
  122. package/components/QiuMsgBox/index.ts +0 -147
  123. package/components/QiuNotification/QiuNotificationItem.vue +0 -168
  124. package/components/QiuNotification/index.ts +0 -155
  125. package/components/QiuNotification/types.ts +0 -14
  126. package/components/QiuPage/index.vue +0 -12
  127. package/components/QiuPageHeader/index.vue +0 -115
  128. package/components/QiuPageMain/index.vue +0 -78
  129. package/components/QiuPagination/index.vue +0 -421
  130. package/components/QiuPasswordStrength/index.vue +0 -101
  131. package/components/QiuPopconfirm/index.vue +0 -196
  132. package/components/QiuPopover/index.vue +0 -277
  133. package/components/QiuProgress/index.vue +0 -235
  134. package/components/QiuQrcode/index.vue +0 -302
  135. package/components/QiuQueryForm/components/QiuQueryFormBottomPanel.vue +0 -13
  136. package/components/QiuQueryForm/components/QiuQueryFormLeftPanel.vue +0 -37
  137. package/components/QiuQueryForm/components/QiuQueryFormRightPanel.vue +0 -37
  138. package/components/QiuQueryForm/components/QiuQueryFormTopPanel.vue +0 -13
  139. package/components/QiuQueryForm/index.vue +0 -117
  140. package/components/QiuRadio/QiuRadioGroup.vue +0 -75
  141. package/components/QiuRadio/index.vue +0 -191
  142. package/components/QiuRadio/types.ts +0 -9
  143. package/components/QiuRate/index.vue +0 -184
  144. package/components/QiuRefresh/index.vue +0 -32
  145. package/components/QiuResult/index.vue +0 -103
  146. package/components/QiuRow/index.vue +0 -60
  147. package/components/QiuRow/types.ts +0 -5
  148. package/components/QiuScrollbar/index.vue +0 -107
  149. package/components/QiuSearchBar/index.vue +0 -154
  150. package/components/QiuSegmented/index.vue +0 -120
  151. package/components/QiuSelect/QiuOption.vue +0 -93
  152. package/components/QiuSelect/index.vue +0 -452
  153. package/components/QiuSelect/types.ts +0 -15
  154. package/components/QiuSkeleton/index.vue +0 -78
  155. package/components/QiuSlider/index.vue +0 -266
  156. package/components/QiuSpace/index.vue +0 -95
  157. package/components/QiuSparkline/index.vue +0 -92
  158. package/components/QiuStatistic/index.vue +0 -4
  159. package/components/QiuStatistics/index.vue +0 -123
  160. package/components/QiuStep/index.vue +0 -220
  161. package/components/QiuSteps/context.ts +0 -25
  162. package/components/QiuSteps/index.vue +0 -84
  163. package/components/QiuSwitch/index.vue +0 -145
  164. package/components/QiuTabBar/index.vue +0 -218
  165. package/components/QiuTabBar/types.ts +0 -19
  166. package/components/QiuTabPane/index.vue +0 -82
  167. package/components/QiuTable/columns.ts +0 -16
  168. package/components/QiuTable/export.ts +0 -102
  169. package/components/QiuTable/index.ts +0 -24
  170. package/components/QiuTable/index.vue +0 -3503
  171. package/components/QiuTable/registry.ts +0 -75
  172. package/components/QiuTable/styles/table.scss +0 -1230
  173. package/components/QiuTable/types.ts +0 -413
  174. package/components/QiuTable/useArea.ts +0 -202
  175. package/components/QiuTable/useColumns.ts +0 -325
  176. package/components/QiuTable/useEdit.ts +0 -136
  177. package/components/QiuTable/useExtras.ts +0 -115
  178. package/components/QiuTable/useHistory.ts +0 -77
  179. package/components/QiuTable/useMerge.ts +0 -105
  180. package/components/QiuTable/useResize.ts +0 -86
  181. package/components/QiuTable/useScrollSync.ts +0 -152
  182. package/components/QiuTable/useVirtual.ts +0 -97
  183. package/components/QiuTable/utils.ts +0 -165
  184. package/components/QiuTableColumn/index.vue +0 -101
  185. package/components/QiuTag/index.vue +0 -143
  186. package/components/QiuText/index.vue +0 -99
  187. package/components/QiuTime/index.vue +0 -548
  188. package/components/QiuTimeAgo/index.vue +0 -103
  189. package/components/QiuTimeline/index.vue +0 -54
  190. package/components/QiuTimelineItem/index.vue +0 -140
  191. package/components/QiuTips/index.vue +0 -323
  192. package/components/QiuTooltip/index.vue +0 -237
  193. package/components/QiuTransfer/index.vue +0 -277
  194. package/components/QiuTree/QiuTreeNode.vue +0 -146
  195. package/components/QiuTree/index.vue +0 -143
  196. package/components/QiuTree/types.ts +0 -30
  197. package/components/QiuTreeSelect/index.vue +0 -259
  198. package/components/QiuTrend/index.vue +0 -92
  199. package/components/QiuUpload/index.vue +0 -628
  200. package/components/QiuUploadCrop/index.vue +0 -1267
  201. package/components/QiuWatermark/index.vue +0 -121
  202. package/components/useBodyScrollLock.ts +0 -28
  203. package/dist/components/QiuBadge/utils/pca-code.json.d.ts +0 -3
  204. package/plugins/qiu.ts +0 -83
  205. package/plugins/shortcuts.ts +0 -69
  206. package/src/bridge/@/api/media.ts +0 -8
  207. package/src/bridge/@/api/notice.ts +0 -5
  208. package/src/bridge/@/config/index.ts +0 -38
  209. package/src/bridge/@/config/media.config.ts +0 -37
  210. package/src/bridge/@/config/security.config.ts +0 -8
  211. package/src/bridge/@/i18n/index.ts +0 -6
  212. package/src/bridge/@/store/modules/bing.ts +0 -9
  213. package/src/bridge/@/store/modules/errorLog.ts +0 -9
  214. package/src/bridge/@/store/modules/media.ts +0 -10
  215. package/src/bridge/@/store/modules/routes.ts +0 -10
  216. package/src/bridge/@/store/modules/settings.ts +0 -9
  217. package/src/bridge/@/store/modules/tabs.ts +0 -9
  218. package/src/bridge/@/store/modules/user.ts +0 -9
  219. package/src/bridge/@/utils/convertToCamelCase.ts +0 -11
  220. package/src/bridge/@/utils/index.ts +0 -9
  221. package/src/bridge/@/utils/pageTitle.ts +0 -4
  222. package/src/bridge/@/utils/routes.ts +0 -14
  223. package/src/bridge/@/utils/validate.ts +0 -17
  224. package/src/bridge/@qiu/plugins/shortcuts.ts +0 -3
  225. package/src/i18n.ts +0 -8
  226. package/src/icons/qiu-logo.svg +0 -1
  227. package/styles/dark.scss +0 -194
  228. package/styles/normalize.css +0 -349
  229. package/styles/plain.scss +0 -423
  230. package/styles/qiu-tokens.scss +0 -40
  231. package/styles/qiu.scss +0 -1149
  232. package/styles/technology.scss +0 -452
  233. package/styles/var.scss +0 -382
  234. package/styles/variables.scss +0 -77
@@ -1,1267 +0,0 @@
1
- <template>
2
- <div class="qiu-upload-crop" :class="{ 'is-disabled': disabled }">
3
- <!-- 触发区 -->
4
- <div
5
- v-if="!hasImage && !cropping"
6
- class="qiu-upload-crop__trigger"
7
- :style="boxStyle"
8
- @click="openPicker"
9
- @dragover.prevent="onDragOver"
10
- @dragleave.prevent="onDragLeave"
11
- @drop.prevent="onDrop"
12
- >
13
- <input
14
- ref="inputRef"
15
- class="qiu-upload-crop__input"
16
- type="file"
17
- :accept="accept"
18
- :disabled="disabled"
19
- @change="onInputChange"
20
- />
21
- <qiu-icon icon="image-add-line" class="qiu-upload-crop__trigger-icon" />
22
- <p class="qiu-upload-crop__trigger-text">{{ buttonText }}</p>
23
- <p v-if="tip" class="qiu-upload-crop__tip">{{ tip }}</p>
24
- </div>
25
-
26
- <!-- 结果预览区 -->
27
- <div
28
- v-else-if="!cropping && result"
29
- class="qiu-upload-crop__result"
30
- :style="boxStyle"
31
- @mouseenter="hover = true"
32
- @mouseleave="hover = false"
33
- >
34
- <img :src="result" class="qiu-upload-crop__result-img" alt="" :style="resultImgStyle" />
35
- <div v-show="hover" class="qiu-upload-crop__mask">
36
- <button type="button" :disabled="disabled" title="重新裁切" @click="cropping = true">
37
- <qiu-icon icon="crop-line" />
38
- </button>
39
- <button type="button" :disabled="disabled" title="查看原图" @click="previewVisible = true">
40
- <qiu-icon icon="zoom-in-line" />
41
- </button>
42
- <button type="button" :disabled="disabled" title="删除" @click="remove">
43
- <qiu-icon icon="delete-bin-line" />
44
- </button>
45
- </div>
46
- </div>
47
-
48
- <!-- 裁剪弹窗 -->
49
- <teleport to="body">
50
- <div v-if="cropping" class="qiu-upload-crop__dialog" @click.self="onShadeClick">
51
- <div class="qiu-upload-crop__dialog-box">
52
- <div class="qiu-upload-crop__dialog-head">
53
- <span>图片裁剪</span>
54
- <button type="button" class="qiu-upload-crop__close" @click="cancel">×</button>
55
- </div>
56
-
57
- <div
58
- ref="stageRef"
59
- class="qiu-upload-crop__stage"
60
- @wheel.prevent="onWheel"
61
- @mousedown="onStageDown"
62
- >
63
- <img
64
- ref="imgRef"
65
- :src="source"
66
- class="qiu-upload-crop__source"
67
- :style="sourceStyle"
68
- alt=""
69
- draggable="false"
70
- />
71
- <div
72
- class="qiu-upload-crop__crop"
73
- :class="{ 'is-round': shape === 'round' }"
74
- :style="cropBoxStyle"
75
- @mousedown.stop="onCropDown"
76
- >
77
- <span
78
- v-for="h in handles"
79
- :key="h"
80
- class="qiu-upload-crop__handle"
81
- :class="`qiu-upload-crop__handle--${h}`"
82
- @mousedown.stop="onHandleDown($event, h)"
83
- />
84
- </div>
85
- </div>
86
-
87
- <div class="qiu-upload-crop__toolbar">
88
- <button
89
- v-if="showRotate"
90
- type="button"
91
- title="左转"
92
- @click="rotateBy(-90)"
93
- >
94
- <qiu-icon icon="anticlockwise-line" />
95
- </button>
96
- <button
97
- v-if="showRotate"
98
- type="button"
99
- title="右转"
100
- @click="rotateBy(90)"
101
- >
102
- <qiu-icon icon="clockwise-line" />
103
- </button>
104
-
105
- <button v-if="showFlip" type="button" title="水平翻转" @click="flipX = -flipX">
106
- <qiu-icon icon="flip-h-line" />
107
- </button>
108
- <button v-if="showFlip" type="button" title="垂直翻转" @click="flipY = -flipY">
109
- <qiu-icon icon="flip-v-line" />
110
- </button>
111
- <button
112
- v-if="showZoom"
113
- type="button"
114
- title="放大"
115
- @click="scale = Math.min(5, +(scale + 0.1).toFixed(2))"
116
- >
117
- <qiu-icon icon="zoom-in-line" />
118
- </button>
119
- <button
120
- v-if="showZoom"
121
- type="button"
122
- title="缩小"
123
- @click="scale = Math.max(0.1, +(scale - 0.1).toFixed(2))"
124
- >
125
- <qiu-icon icon="zoom-out-line" />
126
- </button>
127
- <button type="button" title="重置" @click="reset">
128
- <qiu-icon icon="refresh-line" />
129
- </button>
130
-
131
- <template v-if="showSizeInputs">
132
- <span class="qiu-upload-crop__divider" />
133
-
134
- <label class="qiu-upload-crop__field">
135
- <span>宽</span>
136
- <input
137
- v-model.number="cropWInput"
138
- type="number"
139
- min="20"
140
- @change="applyCropSize('w')"
141
- />
142
- </label>
143
- <label class="qiu-upload-crop__field">
144
- <span>高</span>
145
- <input
146
- v-model.number="cropHInput"
147
- type="number"
148
- min="20"
149
- @change="applyCropSize('h')"
150
- />
151
- </label>
152
- <button type="button" title="应用尺寸" @click="applyCropSize()">应用</button>
153
- </template>
154
-
155
- <span class="qiu-upload-crop__divider" />
156
-
157
- <label class="qiu-upload-crop__quality">
158
- <span>质量 {{ quality }}</span>
159
- <input v-model.number="quality" type="range" min="0.1" max="1" step="0.1" />
160
- </label>
161
-
162
- <slot name="toolbar" />
163
-
164
- <span class="qiu-upload-crop__divider" />
165
-
166
- <button type="button" class="qiu-upload-crop__confirm" @click="confirm">确定</button>
167
- </div>
168
- </div>
169
- </div>
170
-
171
- <div v-if="previewVisible" class="qiu-upload-crop__preview" @click="previewVisible = false">
172
- <img :src="result || source" alt="" />
173
- </div>
174
- </teleport>
175
- </div>
176
- </template>
177
-
178
- <script lang="ts" setup>
179
- /**
180
- * QiuUploadCrop — 图片上传 + Canvas 裁剪(自研,无第三方裁剪依赖)
181
- * 输出 base64(v-model)与 cropper-file 事件(可拿 File 直接上传)
182
- * 支持 fixedAspect 比例锁定、90° 旋转预览并在确认时烘焙进画布
183
- *
184
- * @author qiuye
185
- * @email yjk150@qq.com
186
- * @date 2026-08-16
187
- */
188
- defineOptions({ name: 'QiuUploadCrop' })
189
-
190
- const props = withDefaults(
191
- defineProps<{
192
- modelValue?: string
193
- /** 组件展示宽高(px) */
194
- width?: number
195
- height?: number
196
- /** 允许上传的图片类型后缀 */
197
- type?: string[]
198
- /** 文件大小上限(MB) */
199
- size?: number
200
- disabled?: boolean
201
- buttonText?: string
202
- tip?: string
203
- /** 裁剪框形状 */
204
- shape?: 'rect' | 'round'
205
- /** 是否允许 round 时输出圆形(裁切透明背景) */
206
- circleOutput?: boolean
207
- /** 点击遮罩是否关闭(不保存) */
208
- shadeClose?: boolean
209
- /** 输出图片格式 */
210
- outputType?: 'image/png' | 'image/jpeg' | 'image/webp'
211
- accept?: string
212
- /** 输出图片质量 0.1~1(控制体积,便于存储) */
213
- outputQuality?: number
214
- /** 输出原图裁切结果的目标宽高(px);0 表示按裁剪框自然尺寸 */
215
- outputWidth?: number
216
- outputHeight?: number
217
- /** 是否同时输出缩略图 */
218
- withThumbnail?: boolean
219
- /** 缩略图边长(px) */
220
- thumbnailSize?: number
221
- /** 是否同时输出圆形头像 */
222
- withAvatar?: boolean
223
- /** 头像边长(px) */
224
- avatarSize?: number
225
- /** 初始裁剪框宽(px),0 表示自适应 */
226
- cropInitWidth?: number
227
- /** 初始裁剪框高(px),0 表示自适应 */
228
- cropInitHeight?: number
229
- /** 裁剪框缩放时是否锁定宽高比 */
230
- fixedAspect?: boolean
231
- /**
232
- * 宽/高比。支持 number(如 3.2)或字符串 `"16:5"` / `"16/5"`。
233
- * 为 0 且 fixedAspect 时从 cropInit / output 尺寸推导,否则为 1。
234
- */
235
- aspectRatio?: number | string
236
- /** 工具栏:旋转按钮 */
237
- showRotate?: boolean
238
- /** 工具栏:翻转按钮 */
239
- showFlip?: boolean
240
- /** 工具栏:缩放按钮 */
241
- showZoom?: boolean
242
- /** 工具栏:宽高输入 */
243
- showSizeInputs?: boolean
244
- }>(),
245
- {
246
- modelValue: '',
247
- width: 178,
248
- height: 178,
249
- type: () => ['png', 'jpg', 'jpeg'],
250
- size: 5,
251
- disabled: false,
252
- buttonText: '上传图片',
253
- tip: '',
254
- shape: 'rect',
255
- circleOutput: false,
256
- shadeClose: true,
257
- outputType: 'image/jpeg',
258
- accept: 'image/*',
259
- outputQuality: 0.92,
260
- outputWidth: 0,
261
- outputHeight: 0,
262
- withThumbnail: false,
263
- thumbnailSize: 120,
264
- withAvatar: false,
265
- avatarSize: 160,
266
- cropInitWidth: 0,
267
- cropInitHeight: 0,
268
- fixedAspect: false,
269
- aspectRatio: 0,
270
- showRotate: true,
271
- showFlip: true,
272
- showZoom: true,
273
- showSizeInputs: true,
274
- }
275
- )
276
-
277
- const emit = defineEmits<{
278
- 'update:modelValue': [string]
279
- change: [string]
280
- /** 裁剪原图结果 base64 */
281
- 'cropper-image': [string]
282
- /** 缩略图 base64 */
283
- 'cropper-thumbnail': [string]
284
- /** 圆形头像 base64 */
285
- 'cropper-avatar': [string]
286
- /** 一次性返回全部结果 */
287
- 'cropper-result': [{
288
- image: string
289
- thumbnail: string
290
- avatar: string
291
- }]
292
- 'cropper-file': [File]
293
- remove: []
294
- error: [string]
295
- }>()
296
-
297
- const inputRef = ref<HTMLInputElement | null>(null)
298
- const stageRef = ref<HTMLElement | null>(null)
299
- const imgRef = ref<HTMLImageElement | null>(null)
300
-
301
- const hover = ref(false)
302
- const cropping = ref(false)
303
- const previewVisible = ref(false)
304
-
305
- const source = ref('') // 原图 base64
306
- const result = ref(props.modelValue)
307
- const hasImage = computed(() => !!result.value)
308
-
309
- // 画布内图片变换
310
- const scale = ref(1)
311
- const flipX = ref(1)
312
- const flipY = ref(1)
313
- /** 预览旋转角(0 / 90 / 180 / 270),确认时烘焙进 canvas */
314
- const rotateDeg = ref(0)
315
- const offsetX = ref(0)
316
- const offsetY = ref(0)
317
-
318
- // 裁剪框(相对 stage 坐标)
319
- const cropX = ref(0)
320
- const cropY = ref(0)
321
- const cropW = ref(0)
322
- const cropH = ref(0)
323
- const cropWInput = ref(0)
324
- const cropHInput = ref(0)
325
-
326
- const quality = ref(props.outputQuality)
327
-
328
- /** 解析 aspectRatio:number 或 `"16:5"` / `"16/5"` */
329
- const parseAspectRatio = (v: number | string | undefined): number => {
330
- if (v === undefined || v === null || v === '') return 0
331
- if (typeof v === 'number') return Number.isFinite(v) && v > 0 ? v : 0
332
- const s = String(v).trim()
333
- if (!s) return 0
334
- const sep = s.includes(':') ? ':' : s.includes('/') ? '/' : ''
335
- if (sep) {
336
- const [a, b] = s.split(sep).map((x) => Number(x.trim()))
337
- if (Number.isFinite(a) && Number.isFinite(b) && b !== 0) return a / b
338
- return 0
339
- }
340
- const n = Number(s)
341
- return Number.isFinite(n) && n > 0 ? n : 0
342
- }
343
-
344
- /** 解析后的 W/H;fixedAspect 且未显式给出时按 cropInit → output → 1 回退 */
345
- const resolvedAspect = computed(() => {
346
- const parsed = parseAspectRatio(props.aspectRatio)
347
- if (parsed > 0) return parsed
348
- if (props.cropInitWidth > 0 && props.cropInitHeight > 0) {
349
- return props.cropInitWidth / props.cropInitHeight
350
- }
351
- if (props.outputWidth > 0 && props.outputHeight > 0) {
352
- return props.outputWidth / props.outputHeight
353
- }
354
- return 1
355
- })
356
-
357
- const boxStyle = computed(() => ({
358
- width: `${props.width}px`,
359
- height: `${props.height}px`,
360
- }))
361
-
362
- const resultImgStyle = computed(() => {
363
- if (props.shape === 'round' || props.circleOutput) return { borderRadius: '50%' }
364
- return {}
365
- })
366
-
367
- const sourceStyle = computed(() => ({
368
- transform: `translate(${offsetX.value}px, ${offsetY.value}px) scale(${scale.value}) rotate(${rotateDeg.value}deg) scaleX(${flipX.value}) scaleY(${flipY.value})`,
369
- }))
370
-
371
- const cropBoxStyle = computed(() => ({
372
- left: `${cropX.value}px`,
373
- top: `${cropY.value}px`,
374
- width: `${cropW.value}px`,
375
- height: `${cropH.value}px`,
376
- }))
377
-
378
- const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
379
-
380
- const fileExt = (name: string) => (name.split('.').pop() || '').toLowerCase()
381
- const isAllowed = (name: string) => {
382
- const ext = fileExt(name)
383
- return props.type.includes(ext)
384
- }
385
-
386
- const openPicker = () => {
387
- if (props.disabled) return
388
- inputRef.value?.click()
389
- }
390
- const onDragOver = () => {
391
- if (!props.disabled) stageRef.value?.classList.add('is-dragover')
392
- }
393
- const onDragLeave = () => stageRef.value?.classList.remove('is-dragover')
394
- const onDrop = (e: DragEvent) => {
395
- stageRef.value?.classList.remove('is-dragover')
396
- if (props.disabled) return
397
- const file = e.dataTransfer?.files?.[0]
398
- if (file) handleFile(file)
399
- }
400
-
401
- const onInputChange = (e: Event) => {
402
- const input = e.target as HTMLInputElement
403
- const file = input.files?.[0]
404
- input.value = ''
405
- if (file) handleFile(file)
406
- }
407
-
408
- const handleFile = (file: File) => {
409
- if (!file.type.startsWith('image/')) {
410
- emit('error', '仅支持图片文件')
411
- return
412
- }
413
- if (!isAllowed(file.name)) {
414
- emit('error', `不支持的格式,仅允许:${props.type.join('/')}`)
415
- return
416
- }
417
- if (file.size > props.size * 1024 * 1024) {
418
- emit('error', `文件超过 ${props.size}MB 限制`)
419
- return
420
- }
421
- const reader = new FileReader()
422
- reader.onload = () => {
423
- source.value = reader.result as string
424
- cropping.value = true
425
- nextTick(initCrop)
426
- }
427
- reader.readAsDataURL(file)
428
- }
429
-
430
- /** 在 stage 内按宽高比生成居中裁剪框尺寸 */
431
- const fitAspectBox = (stageW: number, stageH: number, ratio: number, preferW: number, preferH: number) => {
432
- let w = preferW
433
- let h = preferH
434
- if (w <= 0 && h <= 0) {
435
- const maxW = stageW * 0.7
436
- const maxH = stageH * 0.7
437
- if (maxW / maxH > ratio) {
438
- h = maxH
439
- w = h * ratio
440
- } else {
441
- w = maxW
442
- h = w / ratio
443
- }
444
- } else if (w > 0 && h <= 0) {
445
- w = Math.min(w, stageW)
446
- h = w / ratio
447
- if (h > stageH) {
448
- h = stageH
449
- w = h * ratio
450
- }
451
- } else if (h > 0 && w <= 0) {
452
- h = Math.min(h, stageH)
453
- w = h * ratio
454
- if (w > stageW) {
455
- w = stageW
456
- h = w / ratio
457
- }
458
- } else {
459
- // 两者都给了:以宽为基准再夹紧
460
- w = Math.min(w, stageW)
461
- h = w / ratio
462
- if (h > stageH) {
463
- h = Math.min(preferH, stageH)
464
- w = h * ratio
465
- if (w > stageW) {
466
- w = stageW
467
- h = w / ratio
468
- }
469
- }
470
- }
471
- w = Math.max(20, Math.min(w, stageW))
472
- h = Math.max(20, Math.min(w / ratio, stageH))
473
- w = Math.max(20, Math.min(h * ratio, stageW))
474
- h = w / ratio
475
- return { w, h }
476
- }
477
-
478
- const initCrop = () => {
479
- const stage = stageRef.value
480
- if (!stage) return
481
- const rect = stage.getBoundingClientRect()
482
- // 让原图适配 stage
483
- const img = new Image()
484
- img.onload = () => {
485
- const fit = Math.min(rect.width / img.width, rect.height / img.height, 1)
486
- const w = img.width * fit
487
- const h = img.height * fit
488
- scale.value = fit
489
- flipX.value = 1
490
- flipY.value = 1
491
- rotateDeg.value = 0
492
- offsetX.value = (rect.width - w) / 2
493
- offsetY.value = (rect.height - h) / 2
494
-
495
- if (props.fixedAspect) {
496
- const ratio = resolvedAspect.value
497
- const preferW = props.cropInitWidth > 0 ? props.cropInitWidth : 0
498
- const preferH = props.cropInitHeight > 0 ? props.cropInitHeight : 0
499
- const box = fitAspectBox(rect.width, rect.height, ratio, preferW, preferH)
500
- cropW.value = box.w
501
- cropH.value = box.h
502
- } else {
503
- // 裁剪框默认取中心正方形 / 自由矩形
504
- const side = Math.min(rect.width, rect.height) * 0.7
505
- const initW = props.cropInitWidth > 0 ? Math.min(props.cropInitWidth, rect.width) : side
506
- const initH = props.cropInitHeight > 0 ? Math.min(props.cropInitHeight, rect.height) : side
507
- cropW.value = props.shape === 'round' ? Math.min(initW, initH) : Math.min(initW, w)
508
- cropH.value = props.shape === 'round' ? Math.min(initW, initH) : Math.min(initH, h)
509
- }
510
- cropX.value = (rect.width - cropW.value) / 2
511
- cropY.value = (rect.height - cropH.value) / 2
512
- syncCropInput()
513
- }
514
- img.src = source.value
515
- }
516
-
517
- const syncCropInput = () => {
518
- cropWInput.value = Math.round(cropW.value)
519
- cropHInput.value = Math.round(cropH.value)
520
- }
521
-
522
- const clampCropBox = (nx: number, ny: number, nw: number, nh: number, stageW: number, stageH: number) => {
523
- let w = Math.max(20, Math.min(nw, stageW))
524
- let h = Math.max(20, Math.min(nh, stageH))
525
- let x = Math.max(0, Math.min(nx, stageW - w))
526
- let y = Math.max(0, Math.min(ny, stageH - h))
527
- if (x + w > stageW) {
528
- w = stageW - x
529
- }
530
- if (y + h > stageH) {
531
- h = stageH - y
532
- }
533
- return { x, y, w: Math.max(20, w), h: Math.max(20, h) }
534
- }
535
-
536
- /** dim: 用户改了宽或高时,fixedAspect 下联动另一边 */
537
- const applyCropSize = (dim?: 'w' | 'h') => {
538
- const stage = stageRef.value
539
- if (!stage) return
540
- const rect = stage.getBoundingClientRect()
541
- let w = Math.max(20, cropWInput.value || cropW.value)
542
- let h = Math.max(20, cropHInput.value || cropH.value)
543
-
544
- if (props.fixedAspect) {
545
- const ratio = resolvedAspect.value
546
- if (dim === 'h') {
547
- w = h * ratio
548
- } else {
549
- // 改宽或点「应用」:以宽为准
550
- h = w / ratio
551
- }
552
- // 夹紧进 stage,保持比例
553
- if (w > rect.width) {
554
- w = rect.width
555
- h = w / ratio
556
- }
557
- if (h > rect.height) {
558
- h = rect.height
559
- w = h * ratio
560
- }
561
- w = Math.max(20, w)
562
- h = Math.max(20, h)
563
- } else {
564
- w = Math.max(20, Math.min(w, rect.width))
565
- h = Math.max(20, Math.min(h, rect.height))
566
- }
567
-
568
- const clamped = clampCropBox(cropX.value, cropY.value, w, h, rect.width, rect.height)
569
- if (props.fixedAspect) {
570
- const ratio = resolvedAspect.value
571
- let cw = clamped.w
572
- let ch = cw / ratio
573
- if (ch > rect.height) {
574
- ch = Math.min(clamped.h, rect.height)
575
- cw = ch * ratio
576
- }
577
- const final = clampCropBox(clamped.x, clamped.y, cw, ch, rect.width, rect.height)
578
- // 再一次比例校正
579
- let fw = final.w
580
- let fh = fw / ratio
581
- if (final.y + fh > rect.height) {
582
- fh = rect.height - final.y
583
- fw = fh * ratio
584
- }
585
- if (final.x + fw > rect.width) {
586
- fw = rect.width - final.x
587
- fh = fw / ratio
588
- }
589
- cropX.value = Math.max(0, Math.min(final.x, rect.width - fw))
590
- cropY.value = Math.max(0, Math.min(final.y, rect.height - fh))
591
- cropW.value = Math.max(20, fw)
592
- cropH.value = Math.max(20, fh)
593
- } else {
594
- cropX.value = clamped.x
595
- cropY.value = clamped.y
596
- cropW.value = clamped.w
597
- cropH.value = clamped.h
598
- }
599
- syncCropInput()
600
- }
601
-
602
- const reset = () => initCrop()
603
-
604
- const rotateBy = (delta: number) => {
605
- rotateDeg.value = (((rotateDeg.value + delta) % 360) + 360) % 360
606
- }
607
-
608
- const onWheel = (e: WheelEvent) => {
609
- scale.value = Math.max(0.1, Math.min(5, +(scale.value - e.deltaY * 0.001).toFixed(3)))
610
- }
611
-
612
- // 拖拽图片
613
- const onStageDown = (e: MouseEvent) => {
614
- const startX = e.clientX
615
- const startY = e.clientY
616
- const ox = offsetX.value
617
- const oy = offsetY.value
618
- const move = (ev: MouseEvent) => {
619
- offsetX.value = ox + (ev.clientX - startX)
620
- offsetY.value = oy + (ev.clientY - startY)
621
- }
622
- const up = () => {
623
- window.removeEventListener('mousemove', move)
624
- window.removeEventListener('mouseup', up)
625
- }
626
- window.addEventListener('mousemove', move)
627
- window.addEventListener('mouseup', up)
628
- }
629
-
630
- // 拖拽裁剪框
631
- const onCropDown = (e: MouseEvent) => {
632
- const startX = e.clientX
633
- const startY = e.clientY
634
- const ox = cropX.value
635
- const oy = cropY.value
636
- const stage = stageRef.value!.getBoundingClientRect()
637
- const move = (ev: MouseEvent) => {
638
- let nx = ox + (ev.clientX - startX)
639
- let ny = oy + (ev.clientY - startY)
640
- nx = Math.max(0, Math.min(nx, stage.width - cropW.value))
641
- ny = Math.max(0, Math.min(ny, stage.height - cropH.value))
642
- cropX.value = nx
643
- cropY.value = ny
644
- }
645
- const up = () => {
646
- window.removeEventListener('mousemove', move)
647
- window.removeEventListener('mouseup', up)
648
- }
649
- window.addEventListener('mousemove', move)
650
- window.addEventListener('mouseup', up)
651
- }
652
-
653
- /** 固定比例时,将 (nx,ny,nw,nh) 按 ratio 夹紧进 stage */
654
- const lockAspectResize = (
655
- dir: string,
656
- bx: number,
657
- by: number,
658
- bw: number,
659
- bh: number,
660
- dx: number,
661
- dy: number,
662
- stageW: number,
663
- stageH: number,
664
- ratio: number
665
- ) => {
666
- let nx = bx
667
- let ny = by
668
- let nw = bw
669
- let nh = bh
670
-
671
- const applySizeFromWidth = (w: number, anchorRight: boolean, anchorBottom: boolean, centerY: boolean) => {
672
- nw = Math.max(20, w)
673
- nh = nw / ratio
674
- if (anchorRight) nx = bx + bw - nw
675
- if (centerY) ny = by + (bh - nh) / 2
676
- else if (anchorBottom) ny = by + bh - nh
677
- }
678
-
679
- const applySizeFromHeight = (h: number, anchorBottom: boolean, anchorRight: boolean, centerX: boolean) => {
680
- nh = Math.max(20, h)
681
- nw = nh * ratio
682
- if (anchorBottom) ny = by + bh - nh
683
- if (centerX) nx = bx + (bw - nw) / 2
684
- else if (anchorRight) nx = bx + bw - nw
685
- }
686
-
687
- if (dir === 'e' || dir === 'w') {
688
- const rawW = dir === 'e' ? bw + dx : bw - dx
689
- applySizeFromWidth(rawW, dir === 'w', false, true)
690
- } else if (dir === 'n' || dir === 's') {
691
- const rawH = dir === 's' ? bh + dy : bh - dy
692
- applySizeFromHeight(rawH, dir === 'n', false, true)
693
- } else {
694
- // 角点:取拖动更大的边主导
695
- const rawW = dir.includes('e') ? bw + dx : bw - dx
696
- const rawH = dir.includes('s') ? bh + dy : bh - dy
697
- if (Math.abs(dx) * ratio >= Math.abs(dy)) {
698
- applySizeFromWidth(rawW, dir.includes('w'), dir.includes('n'), false)
699
- if (dir.includes('n')) ny = by + bh - nh
700
- } else {
701
- applySizeFromHeight(rawH, dir.includes('n'), dir.includes('w'), false)
702
- if (dir.includes('w')) nx = bx + bw - nw
703
- }
704
- }
705
-
706
- // 夹紧:先限制尺寸,再修正锚点位置
707
- if (nw > stageW) {
708
- nw = stageW
709
- nh = nw / ratio
710
- }
711
- if (nh > stageH) {
712
- nh = stageH
713
- nw = nh * ratio
714
- }
715
- nw = Math.max(20, nw)
716
- nh = Math.max(20, nh)
717
-
718
- if (dir.includes('w')) nx = bx + bw - nw
719
- if (dir.includes('n') && dir !== 'n' && dir !== 's') {
720
- // 角点已设 ny;边缘 n/s 在上面处理
721
- }
722
- if (dir === 'n') ny = by + bh - nh
723
- if (dir.includes('n') && dir.length === 2) ny = by + bh - nh
724
-
725
- nx = Math.max(0, Math.min(nx, stageW - nw))
726
- ny = Math.max(0, Math.min(ny, stageH - nh))
727
-
728
- // 贴边后再缩一次,保证比例仍成立
729
- if (nx + nw > stageW) {
730
- nw = stageW - nx
731
- nh = nw / ratio
732
- if (dir.includes('n')) ny = by + bh - nh
733
- ny = Math.max(0, Math.min(ny, stageH - nh))
734
- }
735
- if (ny + nh > stageH) {
736
- nh = stageH - ny
737
- nw = nh * ratio
738
- if (dir.includes('w')) nx = bx + bw - nw
739
- nx = Math.max(0, Math.min(nx, stageW - nw))
740
- }
741
-
742
- return { nx, ny, nw: Math.max(20, nw), nh: Math.max(20, nh) }
743
- }
744
-
745
- // 调整裁剪框大小
746
- const onHandleDown = (e: MouseEvent, dir: string) => {
747
- const startX = e.clientX
748
- const startY = e.clientY
749
- const bx = cropX.value
750
- const by = cropY.value
751
- const bw = cropW.value
752
- const bh = cropH.value
753
- const stage = stageRef.value!.getBoundingClientRect()
754
- const move = (ev: MouseEvent) => {
755
- const dx = ev.clientX - startX
756
- const dy = ev.clientY - startY
757
- let nx = bx
758
- let ny = by
759
- let nw = bw
760
- let nh = bh
761
-
762
- if (props.fixedAspect) {
763
- const locked = lockAspectResize(dir, bx, by, bw, bh, dx, dy, stage.width, stage.height, resolvedAspect.value)
764
- nx = locked.nx
765
- ny = locked.ny
766
- nw = locked.nw
767
- nh = locked.nh
768
- } else {
769
- if (dir.includes('e')) nw = Math.max(20, Math.min(bw + dx, stage.width - bx))
770
- if (dir.includes('s')) nh = Math.max(20, Math.min(bh + dy, stage.height - by))
771
- if (dir.includes('w')) {
772
- nx = Math.max(0, bx + dx)
773
- nw = bx + bw - nx
774
- }
775
- if (dir.includes('n')) {
776
- ny = Math.max(0, by + dy)
777
- nh = by + bh - ny
778
- }
779
- if (nw < 20) {
780
- nw = 20
781
- if (dir.includes('w')) nx = bx + bw - 20
782
- }
783
- if (nh < 20) {
784
- nh = 20
785
- if (dir.includes('n')) ny = by + bh - 20
786
- }
787
- }
788
-
789
- cropX.value = nx
790
- cropY.value = ny
791
- cropW.value = nw
792
- cropH.value = nh
793
- syncCropInput()
794
- }
795
- const up = () => {
796
- window.removeEventListener('mousemove', move)
797
- window.removeEventListener('mouseup', up)
798
- }
799
- window.addEventListener('mousemove', move)
800
- window.addEventListener('mouseup', up)
801
- }
802
-
803
- const onShadeClick = () => {
804
- if (props.shadeClose) cancel()
805
- }
806
-
807
- const cancel = () => {
808
- cropping.value = false
809
- }
810
-
811
- /**
812
- * 将原图按当前 rotate + flip 烘焙到中间画布,再按裁剪框取样。
813
- * 预览用 CSS transform;确认时必须 bake,否则输出不含旋转/翻转。
814
- *
815
- * Caveat:裁剪框坐标按「旋转后外接矩形」映射(90° 时 W/H 互换),
816
- * 与 CSS transform-origin:center 的视觉大致对齐;极端 offset/scale 下可能有像素级偏差。
817
- */
818
- const buildTransformedSource = (img: HTMLImageElement) => {
819
- const naturalW = img.naturalWidth
820
- const naturalH = img.naturalHeight
821
- const deg = ((rotateDeg.value % 360) + 360) % 360
822
- const rad = (deg * Math.PI) / 180
823
- const swapped = deg === 90 || deg === 270
824
- const outW = swapped ? naturalH : naturalW
825
- const outH = swapped ? naturalW : naturalH
826
- const canvas = document.createElement('canvas')
827
- canvas.width = Math.max(1, outW)
828
- canvas.height = Math.max(1, outH)
829
- const ctx = canvas.getContext('2d')!
830
- ctx.translate(outW / 2, outH / 2)
831
- ctx.rotate(rad)
832
- ctx.scale(flipX.value, flipY.value)
833
- ctx.drawImage(img, -naturalW / 2, -naturalH / 2)
834
- return { canvas, outW, outH, swapped }
835
- }
836
-
837
- const confirm = () => {
838
- const img = imgRef.value
839
- const stage = stageRef.value
840
- if (!img || !stage) return
841
-
842
- const { canvas: srcCanvas, outW: baseW, outH: baseH } = buildTransformedSource(img)
843
-
844
- // stage 内显示尺寸:以旋转后外接尺寸 * scale 估算
845
- const dispW = baseW * scale.value
846
- const dispH = baseH * scale.value
847
- const ratio = baseW / dispW
848
-
849
- const imgLeft = offsetX.value + (stage.clientWidth - dispW) / 2
850
- const imgTop = offsetY.value + (stage.clientHeight - dispH) / 2
851
-
852
- const sx = (cropX.value - imgLeft) * ratio
853
- const sy = (cropY.value - imgTop) * ratio
854
- const sw = cropW.value * ratio
855
- const sh = cropH.value * ratio
856
-
857
- const outW = props.outputWidth > 0 ? props.outputWidth : Math.max(1, Math.round(sw))
858
- const outH = props.outputHeight > 0 ? props.outputHeight : Math.max(1, Math.round(sh))
859
-
860
- const imageURL = drawCrop(srcCanvas, sx, sy, sw, sh, outW, outH, props.outputType, quality.value, false)
861
-
862
- const thumbURL = props.withThumbnail
863
- ? drawCrop(srcCanvas, sx, sy, sw, sh, props.thumbnailSize, props.thumbnailSize, props.outputType, quality.value, false)
864
- : ''
865
-
866
- const avatarType = props.outputType === 'image/jpeg' ? 'image/png' : props.outputType
867
- const avatarURL = props.withAvatar
868
- ? drawCrop(srcCanvas, sx, sy, sw, sh, props.avatarSize, props.avatarSize, avatarType, 1, true)
869
- : ''
870
-
871
- result.value = imageURL
872
- emit('update:modelValue', imageURL)
873
- emit('change', imageURL)
874
- emit('cropper-image', imageURL)
875
- if (thumbURL) emit('cropper-thumbnail', thumbURL)
876
- if (avatarURL) emit('cropper-avatar', avatarURL)
877
- emit('cropper-result', { image: imageURL, thumbnail: thumbURL, avatar: avatarURL })
878
- emit('cropper-file', dataURLtoFile(imageURL, `crop.${extFromType(props.outputType)}`))
879
- cropping.value = false
880
- }
881
-
882
- /**
883
- * 将 source 中 (sx,sy,sw,sh) 区域绘制到目标尺寸画布并返回 dataURL
884
- * @param circle 是否裁为圆形(透明背景)
885
- */
886
- const drawCrop = (
887
- img: CanvasImageSource,
888
- sx: number,
889
- sy: number,
890
- sw: number,
891
- sh: number,
892
- targetW: number,
893
- targetH: number,
894
- type: string,
895
- q: number,
896
- circle: boolean
897
- ) => {
898
- const canvas = document.createElement('canvas')
899
- canvas.width = Math.max(1, Math.round(targetW))
900
- canvas.height = Math.max(1, Math.round(targetH))
901
- const ctx = canvas.getContext('2d')!
902
- if (type === 'image/jpeg' || type === 'image/webp') {
903
- ctx.fillStyle = '#fff'
904
- ctx.fillRect(0, 0, canvas.width, canvas.height)
905
- }
906
- if (circle) {
907
- ctx.beginPath()
908
- ctx.arc(canvas.width / 2, canvas.height / 2, Math.min(canvas.width, canvas.height) / 2, 0, Math.PI * 2)
909
- ctx.closePath()
910
- ctx.clip()
911
- }
912
- // 裁剪区域可能因旋转映射略越界,做最小夹紧
913
- const srcW = 'width' in img && typeof (img as HTMLCanvasElement).width === 'number'
914
- ? (img as HTMLCanvasElement).width
915
- : (img as HTMLImageElement).naturalWidth || 0
916
- const srcH = 'height' in img && typeof (img as HTMLCanvasElement).height === 'number'
917
- ? (img as HTMLCanvasElement).height
918
- : (img as HTMLImageElement).naturalHeight || 0
919
- let csx = sx
920
- let csy = sy
921
- let csw = sw
922
- let csh = sh
923
- if (srcW > 0 && srcH > 0) {
924
- csx = Math.max(0, Math.min(sx, srcW - 1))
925
- csy = Math.max(0, Math.min(sy, srcH - 1))
926
- csw = Math.max(1, Math.min(sw, srcW - csx))
927
- csh = Math.max(1, Math.min(sh, srcH - csy))
928
- }
929
- ctx.drawImage(img, csx, csy, csw, csh, 0, 0, canvas.width, canvas.height)
930
- return canvas.toDataURL(type, q)
931
- }
932
-
933
- const extFromType = (t: string) => (t.includes('png') ? 'png' : t.includes('webp') ? 'webp' : 'jpg')
934
-
935
- const dataURLtoFile = (dataUrl: string, filename: string) => {
936
- const arr = dataUrl.split(',')
937
- const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg'
938
- const bstr = atob(arr[1])
939
- const n = bstr.length
940
- const u8 = new Uint8Array(n)
941
- for (let i = 0; i < n; i++) u8[i] = bstr.charCodeAt(i)
942
- return new File([u8], filename, { type: mime })
943
- }
944
-
945
- const remove = () => {
946
- result.value = ''
947
- source.value = ''
948
- emit('update:modelValue', '')
949
- emit('remove')
950
- }
951
-
952
- watch(
953
- () => props.modelValue,
954
- (v) => {
955
- if (v !== result.value) result.value = v
956
- }
957
- )
958
- </script>
959
-
960
- <style lang="scss" scoped>
961
- .qiu-upload-crop {
962
- display: inline-block;
963
- font-size: 13px;
964
- color: var(--qiu-text-color-regular, #606266);
965
-
966
- &.is-disabled {
967
- opacity: 0.6;
968
- pointer-events: none;
969
- }
970
- }
971
-
972
- .qiu-upload-crop__input {
973
- display: none;
974
- }
975
-
976
- .qiu-upload-crop__trigger {
977
- box-sizing: border-box;
978
- display: flex;
979
- flex-direction: column;
980
- align-items: center;
981
- justify-content: center;
982
- gap: 6px;
983
- cursor: pointer;
984
- background: var(--qiu-fill-color-blank, #fff);
985
- border: 1px dashed var(--qiu-border-color, #dcdfe6);
986
- border-radius: 8px;
987
- transition: border-color 0.2s ease, background 0.2s ease;
988
-
989
- &:hover {
990
- color: var(--qiu-color-primary, #0f766e);
991
- background: color-mix(in srgb, var(--qiu-color-primary, #0f766e) 6%, #fff);
992
- border-color: var(--qiu-color-primary, #0f766e);
993
- }
994
-
995
- &.is-dragover {
996
- color: var(--qiu-color-primary, #0f766e);
997
- background: color-mix(in srgb, var(--qiu-color-primary, #0f766e) 10%, #fff);
998
- border-color: var(--qiu-color-primary, #0f766e);
999
- }
1000
-
1001
- &-icon {
1002
- font-size: 28px;
1003
- }
1004
-
1005
- &-text {
1006
- margin: 0;
1007
- font-size: 13px;
1008
- }
1009
- }
1010
-
1011
- .qiu-upload-crop__tip {
1012
- margin: 2px 0 0;
1013
- font-size: 12px;
1014
- color: var(--qiu-text-color-secondary, #909399);
1015
- }
1016
-
1017
- .qiu-upload-crop__result {
1018
- position: relative;
1019
- box-sizing: border-box;
1020
- overflow: hidden;
1021
- background: var(--qiu-fill-color-lighter, #fafafa);
1022
- border: 1px solid var(--qiu-border-color-lighter, #ebeef5);
1023
- border-radius: 8px;
1024
- }
1025
-
1026
- .qiu-upload-crop__result-img {
1027
- width: 100%;
1028
- height: 100%;
1029
- object-fit: cover;
1030
- }
1031
-
1032
- .qiu-upload-crop__mask {
1033
- position: absolute;
1034
- inset: 0;
1035
- display: flex;
1036
- gap: 6px;
1037
- align-items: center;
1038
- justify-content: center;
1039
- background: rgba(0, 0, 0, 0.45);
1040
-
1041
- button {
1042
- display: inline-flex;
1043
- align-items: center;
1044
- justify-content: center;
1045
- width: 28px;
1046
- height: 28px;
1047
- color: #fff;
1048
- cursor: pointer;
1049
- background: rgba(255, 255, 255, 0.15);
1050
- border: 0;
1051
- border-radius: 6px;
1052
-
1053
- &:hover {
1054
- background: rgba(255, 255, 255, 0.3);
1055
- }
1056
-
1057
- i {
1058
- font-size: 16px;
1059
- }
1060
- }
1061
- }
1062
-
1063
- .qiu-upload-crop__dialog {
1064
- position: fixed;
1065
- inset: 0;
1066
- z-index: 2000;
1067
- display: flex;
1068
- align-items: center;
1069
- justify-content: center;
1070
- background: rgba(0, 0, 0, 0.5);
1071
- }
1072
-
1073
- .qiu-upload-crop__dialog-box {
1074
- display: flex;
1075
- flex-direction: column;
1076
- width: min(720px, 92vw);
1077
- max-height: 90vh;
1078
- overflow: hidden;
1079
- background: #fff;
1080
- border-radius: 10px;
1081
- box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
1082
- }
1083
-
1084
- .qiu-upload-crop__dialog-head {
1085
- display: flex;
1086
- align-items: center;
1087
- justify-content: space-between;
1088
- padding: 12px 16px;
1089
- font-size: 15px;
1090
- font-weight: 600;
1091
- border-bottom: 1px solid var(--qiu-border-color-lighter, #ebeef5);
1092
- }
1093
-
1094
- .qiu-upload-crop__close {
1095
- padding: 0 6px;
1096
- font-size: 20px;
1097
- color: var(--qiu-text-color-secondary, #909399);
1098
- cursor: pointer;
1099
- background: transparent;
1100
- border: 0;
1101
- }
1102
-
1103
- .qiu-upload-crop__stage {
1104
- position: relative;
1105
- height: min(460px, 56vh);
1106
- overflow: hidden;
1107
- background:
1108
- linear-gradient(45deg, #f0f0f0 25%, transparent 25%) -10px 0 / 20px 20px,
1109
- linear-gradient(-45deg, #f0f0f0 25%, transparent 25%) -10px 0 / 20px 20px,
1110
- linear-gradient(45deg, transparent 75%, #f0f0f0 75%) -10px 0 / 20px 20px,
1111
- linear-gradient(-45deg, transparent 75%, #f0f0f0 75%) -10px 0 / 20px 20px,
1112
- #fff;
1113
- user-select: none;
1114
- }
1115
-
1116
- .qiu-upload-crop__source {
1117
- position: absolute;
1118
- top: 50%;
1119
- left: 50%;
1120
- max-width: none;
1121
- transform-origin: center center;
1122
- transform: translate(-50%, -50%);
1123
- will-change: transform;
1124
- }
1125
-
1126
- .qiu-upload-crop__crop {
1127
- position: absolute;
1128
- box-sizing: border-box;
1129
- border: 1px solid var(--qiu-color-primary, #0f766e);
1130
- box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
1131
- cursor: move;
1132
-
1133
- &.is-round {
1134
- border-radius: 50%;
1135
- }
1136
-
1137
- &::before,
1138
- &::after {
1139
- position: absolute;
1140
- content: '';
1141
- background: rgba(255, 255, 255, 0.6);
1142
- }
1143
-
1144
- &::before {
1145
- top: 50%;
1146
- left: 0;
1147
- width: 100%;
1148
- height: 1px;
1149
- transform: translateY(-50%);
1150
- }
1151
-
1152
- &::after {
1153
- top: 0;
1154
- left: 50%;
1155
- width: 1px;
1156
- height: 100%;
1157
- transform: translateX(-50%);
1158
- }
1159
- }
1160
-
1161
- .qiu-upload-crop__handle {
1162
- position: absolute;
1163
- width: 10px;
1164
- height: 10px;
1165
- background: #fff;
1166
- border: 1px solid var(--qiu-color-primary, #0f766e);
1167
- border-radius: 2px;
1168
-
1169
- &--nw { top: -6px; left: -6px; cursor: nwse-resize; }
1170
- &--n { top: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
1171
- &--ne { top: -6px; right: -6px; cursor: nesw-resize; }
1172
- &--e { top: 50%; right: -6px; transform: translateY(-50%); cursor: ew-resize; }
1173
- &--se { right: -6px; bottom: -6px; cursor: nwse-resize; }
1174
- &--s { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
1175
- &--sw { bottom: -6px; left: -6px; cursor: nesw-resize; }
1176
- &--w { top: 50%; left: -6px; transform: translateY(-50%); cursor: ew-resize; }
1177
- }
1178
-
1179
- .qiu-upload-crop__toolbar {
1180
- display: flex;
1181
- flex-wrap: wrap;
1182
- gap: 8px;
1183
- align-items: center;
1184
- padding: 12px 16px;
1185
- border-top: 1px solid var(--qiu-border-color-lighter, #ebeef5);
1186
-
1187
- button {
1188
- display: inline-flex;
1189
- align-items: center;
1190
- justify-content: center;
1191
- min-width: 30px;
1192
- height: 30px;
1193
- padding: 0 8px;
1194
- color: var(--qiu-text-color-regular, #606266);
1195
- cursor: pointer;
1196
- background: var(--qiu-fill-color-lighter, #fafafa);
1197
- border: 1px solid var(--qiu-border-color-lighter, #ebeef5);
1198
- border-radius: 6px;
1199
-
1200
- &:hover {
1201
- color: var(--qiu-color-primary, #0f766e);
1202
- border-color: var(--qiu-color-primary, #0f766e);
1203
- }
1204
-
1205
- i {
1206
- font-size: 16px;
1207
- }
1208
- }
1209
- }
1210
-
1211
- .qiu-upload-crop__divider {
1212
- width: 1px;
1213
- height: 20px;
1214
- background: var(--qiu-border-color-lighter, #ebeef5);
1215
- }
1216
-
1217
- .qiu-upload-crop__field {
1218
- display: inline-flex;
1219
- gap: 4px;
1220
- align-items: center;
1221
- font-size: 12px;
1222
- color: var(--qiu-text-color-secondary, #909399);
1223
-
1224
- input {
1225
- width: 56px;
1226
- height: 28px;
1227
- padding: 0 6px;
1228
- border: 1px solid var(--qiu-border-color, #dcdfe6);
1229
- border-radius: 4px;
1230
- }
1231
- }
1232
-
1233
- .qiu-upload-crop__quality {
1234
- display: inline-flex;
1235
- gap: 6px;
1236
- align-items: center;
1237
- font-size: 12px;
1238
- color: var(--qiu-text-color-secondary, #909399);
1239
-
1240
- input {
1241
- width: 90px;
1242
- }
1243
- }
1244
-
1245
- .qiu-upload-crop__confirm {
1246
- margin-left: auto;
1247
- color: #fff !important;
1248
- background: var(--qiu-color-primary, #0f766e) !important;
1249
- border-color: var(--qiu-color-primary, #0f766e) !important;
1250
- }
1251
-
1252
- .qiu-upload-crop__preview {
1253
- position: fixed;
1254
- inset: 0;
1255
- z-index: 2100;
1256
- display: flex;
1257
- align-items: center;
1258
- justify-content: center;
1259
- padding: 24px;
1260
- background: rgba(0, 0, 0, 0.7);
1261
-
1262
- img {
1263
- max-width: 90vw;
1264
- max-height: 90vh;
1265
- }
1266
- }
1267
- </style>