SetComponentBackground.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030
  1. <template>
  2. <el-dialog
  3. custom-class="component-background"
  4. :width="dialogWidth"
  5. :close-on-click-modal="false"
  6. :visible="visible"
  7. :before-close="handleClose"
  8. >
  9. <div class="background-title">组件背景设置</div>
  10. <div class="set-background">
  11. <div
  12. ref="backgroundImg"
  13. class="background-img"
  14. :style="{ width: `${maxWidth}px`, height: `${maxHeight}px` }"
  15. :class="{ 'is-crop-mode': cropMode }"
  16. @mousedown="onBackgroundMouseDown"
  17. >
  18. <div v-if="file_url" class="img-set" :style="{ top: imgTop, left: imgLeft, position: 'relative' }">
  19. <div class="dot top-left" @mousedown="dragStart($event, 'nwse-resize', 'top-left')"></div>
  20. <div class="horizontal-line" @mousedown="dragStart($event, 'ns-resize', 'top')"></div>
  21. <div class="dot top-right" @mousedown="dragStart($event, 'nesw-resize', 'top-right')"></div>
  22. <div class="vertical-line" @mousedown="dragStart($event, 'ew-resize', 'left')"></div>
  23. <img
  24. ref="img"
  25. :src="file_url"
  26. draggable="false"
  27. :style="{ width: imgWidth, height: imgHeight }"
  28. @load="handleImageLoad"
  29. @mousedown="dragStart($event, 'move', 'move')"
  30. />
  31. <div class="vertical-line" @mousedown="dragStart($event, 'ew-resize', 'right')"></div>
  32. <div class="dot bottom-left" @mousedown="dragStart($event, 'nesw-resize', 'bottom-left')"></div>
  33. <div class="horizontal-line" @mousedown="dragStart($event, 'ns-resize', 'bottom')"></div>
  34. <div class="dot bottom-right" @mousedown="dragStart($event, 'nwse-resize', 'bottom-right')"></div>
  35. </div>
  36. <div v-if="file_url && cropMode" class="crop-tip">在图片上拖拽框选裁切区域</div>
  37. <div v-if="file_url && cropMode && hasCropSelection" class="crop-selection" :style="cropSelectionStyle"></div>
  38. </div>
  39. <div class="setup">
  40. <div class="setup-item">
  41. <div class="setup-top">
  42. <el-radio v-model="background.mode" label="image">图片</el-radio>
  43. <SvgIcon v-if="file_url" icon-class="delete" style="cursor: pointer" size="14" @click="file_url = ''" />
  44. </div>
  45. <div class="setup-content">
  46. <div class="image-set">
  47. <el-upload
  48. ref="upload"
  49. class="file-uploader"
  50. action="no"
  51. accept="image/*"
  52. :show-file-list="false"
  53. :limit="1"
  54. :http-request="uploadFile"
  55. >
  56. <span class="select-button">选择图片</span>
  57. </el-upload>
  58. <div>
  59. <span class="opacity-icon"></span>
  60. <el-input v-model="background.image_opacity" size="mini" :min="0" :max="100" style="width: 55px" />%
  61. </div>
  62. </div>
  63. <div class="mode-list">
  64. <span
  65. v-for="mode in imageModeList"
  66. :key="mode.value"
  67. :class="{ active: mode.value === background.imageMode }"
  68. :style="{ fontSize: mode.value === imageModeList[3].value ? '12px' : '' }"
  69. @click="background.imageMode = mode.value"
  70. >
  71. {{ mode.label }}
  72. </span>
  73. </div>
  74. <div v-if="file_url" class="crop-actions">
  75. <el-button size="mini" plain @click="startRectCrop">裁切</el-button>
  76. <el-button v-if="cropMode" size="mini" @click="cancelRectCrop">取消</el-button>
  77. <el-button v-if="cropMode" size="mini" type="primary" @click="applyRectCrop">应用</el-button>
  78. </div>
  79. <div v-if="background.imageMode === imageModeList[3].value">
  80. <el-checkbox v-model="lockScale">锁定比例</el-checkbox>
  81. </div>
  82. </div>
  83. </div>
  84. <div class="setup-item">
  85. <div class="setup-top">
  86. <el-radio v-model="background.mode" label="color">颜色</el-radio>
  87. </div>
  88. <div class="setup-content">
  89. <el-color-picker v-model="background.color" show-alpha />
  90. </div>
  91. </div>
  92. <div class="setup-item">
  93. <div class="setup-top">
  94. <el-checkbox v-model="background.enable_border" label="边框" />
  95. </div>
  96. <div class="setup-content">
  97. <el-color-picker v-model="background.border_color" show-alpha />
  98. <div>
  99. <el-input v-model="background.border_width" style="width: 70px; margin-right: 10px">
  100. <i slot="prefix" class="el-icon-s-fold" style="line-height: 32px"></i>
  101. </el-input>
  102. <el-select v-model="background.border_style" placeholder="边框样式" style="width: 120px">
  103. <el-option label="实线" value="solid" />
  104. <el-option label="虚线" value="dashed" />
  105. <el-option label="点线" value="dotted" />
  106. </el-select>
  107. </div>
  108. </div>
  109. </div>
  110. <div class="setup-item">
  111. <div class="setup-top">
  112. <el-checkbox v-model="background.enable_radius" label="圆角" />
  113. </div>
  114. <div class="setup-content border-radius">
  115. <div class="radius-item">
  116. <span class="span-radius" :style="computedBorderRadius('top', 'left')"></span>
  117. <el-input v-model="background.top_left_radius" :min="0" type="number" />
  118. </div>
  119. <div class="radius-item">
  120. <span class="span-radius" :style="computedBorderRadius('top', 'right')"></span>
  121. <el-input v-model="background.top_right_radius" :min="0" type="number" />
  122. </div>
  123. <div class="radius-item">
  124. <span class="span-radius" :style="computedBorderRadius('bottom', 'left')"></span>
  125. <el-input v-model="background.bottom_left_radius" :min="0" type="number" />
  126. </div>
  127. <div class="radius-item">
  128. <span class="span-radius" :style="computedBorderRadius('bottom', 'right')"></span>
  129. <el-input v-model="background.bottom_right_radius" :min="0" type="number" />
  130. </div>
  131. </div>
  132. </div>
  133. </div>
  134. </div>
  135. <div slot="footer">
  136. <el-button @click="handleClose">取 消</el-button>
  137. <el-button type="primary" @click="confirm">确 定</el-button>
  138. </div>
  139. </el-dialog>
  140. </template>
  141. <script>
  142. import { fileUpload } from '@/api/app';
  143. export default {
  144. name: 'SetComponentBackground',
  145. props: {
  146. visible: {
  147. type: Boolean,
  148. required: true,
  149. },
  150. url: {
  151. type: String,
  152. default: '',
  153. },
  154. position: {
  155. type: Object,
  156. default: () => ({ width: 0, height: 0, top: 0, left: 0 }),
  157. },
  158. backgroundData: {
  159. type: Object,
  160. default: () => ({}),
  161. },
  162. componentData: {
  163. type: Object,
  164. default: () => ({ courseware_id: '', component_id: '' }),
  165. },
  166. },
  167. data() {
  168. return {
  169. maxWidth: 500,
  170. maxHeight: 450,
  171. imgData: {
  172. width: 0,
  173. height: 0,
  174. top: 0,
  175. left: 0,
  176. },
  177. drag: {
  178. dragging: false,
  179. startX: 0,
  180. startY: 0,
  181. type: '',
  182. aspectRatio: 1, // 锁定比例时的宽高比
  183. },
  184. cropMode: false,
  185. crop: {
  186. drawing: false,
  187. startX: 0,
  188. startY: 0,
  189. x: 0,
  190. y: 0,
  191. width: 0,
  192. height: 0,
  193. },
  194. imageDisplay: {
  195. top: 0,
  196. left: 0,
  197. width: 0,
  198. height: 0,
  199. },
  200. file_url: '', // 背景图片地址
  201. background: {
  202. mode: 'image', // 背景模式
  203. imageMode: 'auto', // 图片背景模式
  204. image_opacity: 100, // 背景图片透明度
  205. color: 'rgba(255, 255, 255, 1)', // 背景颜色
  206. enable_border: false, // 是否启用边框
  207. border_color: 'rgba(0, 0, 0, 1)', // 边框颜色
  208. border_width: 1, // 边框宽度
  209. border_style: 'solid', // 边框样式
  210. enable_radius: false, // 是否启用圆角
  211. top_left_radius: 4, // 左上圆角
  212. top_right_radius: 4, // 右上圆角
  213. bottom_left_radius: 4, // 左下圆角
  214. bottom_right_radius: 4, // 右下圆角
  215. },
  216. imageModeList: [
  217. { label: '适应', value: 'adapt' },
  218. { label: '拉伸', value: 'stretch' },
  219. { label: '平铺', value: 'fill' },
  220. { label: '自定义', value: 'auto' },
  221. ],
  222. lockScale: false, // 是否锁定比例(仅自定义模式)
  223. };
  224. },
  225. computed: {
  226. imgTop() {
  227. return `${this.imgData.top - 9}px`;
  228. },
  229. imgLeft() {
  230. return `${this.imgData.left}px`;
  231. },
  232. imgWidth() {
  233. return `${this.imgData.width}px`;
  234. },
  235. imgHeight() {
  236. return `${this.imgData.height}px`;
  237. },
  238. hasCropSelection() {
  239. return this.crop.width > 1 && this.crop.height > 1;
  240. },
  241. cropSelectionStyle() {
  242. return {
  243. top: `${this.imageDisplay.top + this.crop.y}px`,
  244. left: `${this.imageDisplay.left + this.crop.x}px`,
  245. width: `${this.crop.width}px`,
  246. height: `${this.crop.height}px`,
  247. };
  248. },
  249. dialogWidth() {
  250. return `${this.maxWidth + 250}px`;
  251. },
  252. },
  253. watch: {
  254. visible(newVal) {
  255. if (!newVal) {
  256. this.revertData();
  257. return;
  258. }
  259. const component = document.querySelector(`div.${this.componentData.component_id}`);
  260. if (component) {
  261. const rect = component.getBoundingClientRect();
  262. const componentRatio = rect.width / rect.height;
  263. const imgRatio = this.maxWidth / this.maxHeight;
  264. if (componentRatio > imgRatio) {
  265. this.maxHeight = this.maxWidth / componentRatio;
  266. } else {
  267. this.maxWidth = this.maxHeight * componentRatio;
  268. }
  269. }
  270. this.file_url = this.url;
  271. this.imgData = {
  272. width: (this.position.width * this.maxWidth) / 100,
  273. height: (this.position.height * this.maxHeight) / 100,
  274. top: (this.position.top * this.maxHeight) / 100,
  275. left: (this.position.left * this.maxWidth) / 100,
  276. };
  277. if (this.backgroundData && Object.keys(this.backgroundData).length) {
  278. this.background = { ...this.backgroundData };
  279. }
  280. this.cropMode = false;
  281. this.resetCropRect();
  282. this.$nextTick(() => {
  283. document.querySelector('.background-img').addEventListener('mousemove', this.mouseMove);
  284. this.syncImageDisplayRect();
  285. });
  286. },
  287. },
  288. mounted() {
  289. document.body.addEventListener('mouseup', this.mouseUp);
  290. },
  291. beforeDestroy() {
  292. document.querySelector('.background-img')?.removeEventListener('mousemove', this.mouseMove);
  293. document.body.removeEventListener('mouseup', this.mouseUp);
  294. },
  295. methods: {
  296. handleClose() {
  297. this.$emit('update:visible', false);
  298. },
  299. /**
  300. * 还原数据
  301. */
  302. revertData() {
  303. this.cropMode = false;
  304. this.resetCropRect();
  305. this.maxWidth = 500;
  306. this.maxHeight = 450;
  307. this.imgData = {
  308. width: 0,
  309. height: 0,
  310. top: 0,
  311. left: 0,
  312. };
  313. this.crop = {
  314. drawing: false,
  315. startX: 0,
  316. startY: 0,
  317. x: 0,
  318. y: 0,
  319. width: 0,
  320. height: 0,
  321. };
  322. this.imageDisplay = {
  323. top: 0,
  324. left: 0,
  325. width: 0,
  326. height: 0,
  327. };
  328. this.file_url = ''; // 背景图片地址
  329. this.background = {
  330. mode: 'image', // 背景模式
  331. imageMode: 'auto', // 图片背景模式
  332. image_opacity: 100, // 背景图片透明度
  333. color: 'rgba(255, 255, 255, 1)', // 背景颜色
  334. enable_border: false, // 是否启用边框
  335. border_color: 'rgba(0, 0, 0, 1)', // 边框颜色
  336. border_width: 1, // 边框宽度
  337. border_style: 'solid', // 边框样式
  338. enable_radius: false, // 是否启用圆角
  339. top_left_radius: 4, // 左上圆角
  340. top_right_radius: 4, // 右上圆角
  341. bottom_left_radius: 4, // 左下圆角
  342. bottom_right_radius: 4, // 右下圆角
  343. };
  344. },
  345. /**
  346. * 拖拽开始
  347. * @param {MouseEvent} event
  348. * @param {string} cursor
  349. * @param {string} type
  350. */
  351. dragStart(event, cursor, type) {
  352. if (this.cropMode) return;
  353. const { clientX, clientY } = event;
  354. const aspectRatio = this.imgData.height ? this.imgData.width / this.imgData.height : 1;
  355. this.drag = {
  356. dragging: true,
  357. startX: clientX,
  358. startY: clientY,
  359. type,
  360. aspectRatio,
  361. };
  362. document.querySelector('.el-dialog__wrapper').style.cursor = cursor;
  363. },
  364. /**
  365. * 鼠标移动
  366. * @param {MouseEvent} event
  367. */
  368. mouseMove(event) {
  369. if (this.cropMode) {
  370. if (!this.crop.drawing) return;
  371. const point = this.getPointInImage(event);
  372. if (!point) return;
  373. const x = Math.min(this.crop.startX, point.x);
  374. const y = Math.min(this.crop.startY, point.y);
  375. const width = Math.abs(point.x - this.crop.startX);
  376. const height = Math.abs(point.y - this.crop.startY);
  377. this.crop = {
  378. ...this.crop,
  379. x,
  380. y,
  381. width,
  382. height,
  383. };
  384. return;
  385. }
  386. if (!this.drag.dragging) return;
  387. const { clientX, clientY } = event;
  388. const { startX, startY, type } = this.drag;
  389. const prevImgData = { ...this.imgData };
  390. const widthDiff = clientX - startX;
  391. const heightDiff = clientY - startY;
  392. if (type === 'top-left') {
  393. this.imgData.width = Math.min(this.maxWidth, Math.max(0, this.imgData.width - widthDiff));
  394. this.imgData.height = Math.min(this.maxHeight, Math.max(0, this.imgData.height - heightDiff));
  395. this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
  396. this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
  397. } else if (type === 'top-right') {
  398. this.imgData.width = Math.min(this.maxWidth, this.imgData.width + widthDiff);
  399. this.imgData.height = Math.min(this.maxHeight, Math.max(0, this.imgData.height - heightDiff));
  400. this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
  401. this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left));
  402. } else if (type === 'bottom-left') {
  403. this.imgData.width = Math.min(this.maxWidth, Math.max(0, this.imgData.width - widthDiff));
  404. this.imgData.height = Math.min(this.maxHeight, this.imgData.height + heightDiff);
  405. this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top));
  406. this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
  407. } else if (type === 'bottom-right') {
  408. this.imgData.width = Math.min(this.maxWidth, this.imgData.width + widthDiff);
  409. this.imgData.height = Math.min(this.maxHeight, this.imgData.height + heightDiff);
  410. this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top));
  411. this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left));
  412. }
  413. if (type === 'top') {
  414. this.imgData.height = Math.min(this.maxHeight, Math.max(0, this.imgData.height - heightDiff));
  415. this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
  416. } else if (type === 'bottom') {
  417. this.imgData.height = Math.min(this.maxHeight, this.imgData.height + heightDiff);
  418. this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top));
  419. } else if (type === 'left') {
  420. this.imgData.width = Math.min(this.maxWidth, this.imgData.width - widthDiff);
  421. this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
  422. } else if (type === 'right') {
  423. this.imgData.width = Math.min(this.maxWidth, this.imgData.width + widthDiff);
  424. this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left));
  425. }
  426. if (type === 'move') {
  427. this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
  428. this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
  429. }
  430. if (this.shouldLockScale(type)) {
  431. this.applyLockedScaleResize(type, prevImgData);
  432. }
  433. this.drag.startX = clientX;
  434. this.drag.startY = clientY;
  435. this.syncImageDisplayRect();
  436. },
  437. /**
  438. * 判断在当前拖动类型下是否应该锁定比例调整尺寸
  439. * @param {string} type 拖动类型
  440. * @returns {boolean} 是否应该锁定比例调整尺寸
  441. */
  442. shouldLockScale(type) {
  443. return this.background.imageMode === 'auto' && this.lockScale && type !== 'move';
  444. },
  445. /**
  446. * 在锁定比例的情况下调整尺寸,确保宽高比保持不变,并且不会超出边界
  447. * @param {string} type 拖动类型
  448. * @param {object} prevImgData 拖动前的图片数据,用于计算边界限制
  449. */
  450. applyLockedScaleResize(type, prevImgData) {
  451. const ratio = this.drag.aspectRatio || 1;
  452. if (!ratio) return;
  453. let width = type === 'top' || type === 'bottom' ? this.imgData.height * ratio : this.imgData.width;
  454. let height = width / ratio;
  455. // 根据拖动方向计算允许的最大宽高,确保在锁定比例时不会超出边界
  456. const widthLimit = type.includes('left')
  457. ? prevImgData.left + prevImgData.width
  458. : this.maxWidth - prevImgData.left;
  459. const heightLimit = type.includes('top')
  460. ? prevImgData.top + prevImgData.height
  461. : this.maxHeight - prevImgData.top;
  462. const maxWidthByHeight = heightLimit * ratio;
  463. const safeWidth = Math.max(1, Math.min(width, widthLimit, maxWidthByHeight));
  464. width = safeWidth;
  465. height = width / ratio;
  466. // 根据拖动方向计算新的 left 和 top,确保在锁定比例时图片位置调整合理
  467. const left = type.includes('left') ? prevImgData.left + prevImgData.width - width : prevImgData.left;
  468. const top = type.includes('top') ? prevImgData.top + prevImgData.height - height : prevImgData.top;
  469. this.imgData.width = width;
  470. this.imgData.height = height;
  471. this.imgData.left = Math.min(this.maxWidth - width, Math.max(0, left));
  472. this.imgData.top = Math.min(this.maxHeight - height, Math.max(0, top));
  473. },
  474. /**
  475. * 鼠标抬起
  476. */
  477. mouseUp() {
  478. this.crop.drawing = false;
  479. this.drag.dragging = false;
  480. document.querySelector('.el-dialog__wrapper').style.cursor = 'auto';
  481. },
  482. onBackgroundMouseDown(event) {
  483. if (!this.cropMode || !this.file_url) return;
  484. const point = this.getPointInImage(event);
  485. if (!point) return;
  486. this.crop = {
  487. ...this.crop,
  488. drawing: true,
  489. startX: point.x,
  490. startY: point.y,
  491. x: point.x,
  492. y: point.y,
  493. width: 0,
  494. height: 0,
  495. };
  496. event.preventDefault();
  497. },
  498. getPointInImage(event) {
  499. const bg = this.$refs.backgroundImg;
  500. if (!bg || this.imageDisplay.width <= 0 || this.imageDisplay.height <= 0) return null;
  501. const bgRect = bg.getBoundingClientRect();
  502. const rawX = event.clientX - bgRect.left - this.imageDisplay.left;
  503. const rawY = event.clientY - bgRect.top - this.imageDisplay.top;
  504. if (rawX < 0 || rawY < 0 || rawX > this.imageDisplay.width || rawY > this.imageDisplay.height) {
  505. return null;
  506. }
  507. return {
  508. x: Math.min(this.imageDisplay.width, Math.max(0, rawX)),
  509. y: Math.min(this.imageDisplay.height, Math.max(0, rawY)),
  510. };
  511. },
  512. /**
  513. * 同步图片显示区域位置和尺寸信息
  514. */
  515. syncImageDisplayRect() {
  516. this.$nextTick(() => {
  517. const bg = this.$refs.backgroundImg;
  518. const img = this.$refs.img;
  519. if (!bg || !img) {
  520. this.imageDisplay = {
  521. top: 0,
  522. left: 0,
  523. width: 0,
  524. height: 0,
  525. };
  526. return;
  527. }
  528. const bgRect = bg.getBoundingClientRect();
  529. const imgRect = img.getBoundingClientRect();
  530. this.imageDisplay = {
  531. top: imgRect.top - bgRect.top,
  532. left: imgRect.left - bgRect.left,
  533. width: imgRect.width,
  534. height: imgRect.height,
  535. };
  536. });
  537. },
  538. resetCropRect() {
  539. this.crop = {
  540. drawing: false,
  541. startX: 0,
  542. startY: 0,
  543. x: 0,
  544. y: 0,
  545. width: 0,
  546. height: 0,
  547. };
  548. },
  549. startRectCrop() {
  550. if (!this.file_url) return;
  551. this.cropMode = true;
  552. this.syncImageDisplayRect();
  553. this.$nextTick(() => {
  554. const { width, height } = this.imageDisplay;
  555. if (!width || !height) return;
  556. this.crop = {
  557. drawing: false,
  558. startX: 0,
  559. startY: 0,
  560. x: width * 0.25,
  561. y: height * 0.25,
  562. width: width * 0.5,
  563. height: height * 0.5,
  564. };
  565. });
  566. },
  567. cancelRectCrop() {
  568. this.cropMode = false;
  569. this.resetCropRect();
  570. },
  571. async applyRectCrop() {
  572. if (!this.hasCropSelection) {
  573. this.$message.warning('请先框选裁切区域');
  574. return;
  575. }
  576. if (!this.file_url || !this.imageDisplay.width || !this.imageDisplay.height) return;
  577. let sourceImg = null;
  578. let revokeObjectURL = null;
  579. try {
  580. const cropSource = await this.getCropSourceImage();
  581. sourceImg = cropSource.img;
  582. revokeObjectURL = cropSource.revokeObjectURL;
  583. } catch (error) {
  584. this.$message.error('当前图片不支持裁切,请检查图片服务跨域配置');
  585. return;
  586. }
  587. const naturalWidth = sourceImg.naturalWidth;
  588. const naturalHeight = sourceImg.naturalHeight;
  589. const displayWidth = this.imageDisplay.width;
  590. const displayHeight = this.imageDisplay.height;
  591. const sx = Math.max(0, Math.round((this.crop.x / displayWidth) * naturalWidth));
  592. const sy = Math.max(0, Math.round((this.crop.y / displayHeight) * naturalHeight));
  593. const sw = Math.max(1, Math.round((this.crop.width / displayWidth) * naturalWidth));
  594. const sh = Math.max(1, Math.round((this.crop.height / displayHeight) * naturalHeight));
  595. const safeWidth = Math.max(1, Math.min(sw, naturalWidth - sx));
  596. const safeHeight = Math.max(1, Math.min(sh, naturalHeight - sy));
  597. try {
  598. const canvas = document.createElement('canvas');
  599. canvas.width = safeWidth;
  600. canvas.height = safeHeight;
  601. const ctx = canvas.getContext('2d');
  602. if (!ctx) throw new Error('canvas context unavailable');
  603. ctx.drawImage(sourceImg, sx, sy, safeWidth, safeHeight, 0, 0, safeWidth, safeHeight);
  604. const blob = await new Promise((resolve, reject) => {
  605. canvas.toBlob((result) => {
  606. if (result) {
  607. resolve(result);
  608. return;
  609. }
  610. reject(new Error('toBlob failed'));
  611. }, 'image/png');
  612. });
  613. const cropFile = new File([blob], `background-crop-${Date.now()}.png`, { type: 'image/png' });
  614. const uploadPayload = {
  615. filename: 'file',
  616. file: cropFile,
  617. };
  618. const { file_info_list } = await fileUpload('Mid', uploadPayload, { isGlobalprogress: true });
  619. if (!file_info_list || !file_info_list.length) {
  620. throw new Error('upload failed');
  621. }
  622. this.file_url = file_info_list[0].file_url;
  623. this.normalComputed(safeWidth, safeHeight);
  624. this.cancelRectCrop();
  625. this.$message.success('裁切成功');
  626. } catch (error) {
  627. this.$message.error('裁切失败');
  628. console.error('Rect crop error:', error);
  629. } finally {
  630. revokeObjectURL?.();
  631. }
  632. },
  633. async getCropSourceImage() {
  634. try {
  635. const img = await this.loadImage(this.file_url, 'anonymous');
  636. return {
  637. img,
  638. revokeObjectURL: null,
  639. };
  640. } catch (crossOriginError) {
  641. const blob = await this.fetchImageBlob(this.file_url);
  642. const objectUrl = URL.createObjectURL(blob);
  643. try {
  644. const img = await this.loadImage(objectUrl);
  645. return {
  646. img,
  647. revokeObjectURL: () => URL.revokeObjectURL(objectUrl),
  648. };
  649. } catch (loadBlobError) {
  650. URL.revokeObjectURL(objectUrl);
  651. throw loadBlobError;
  652. }
  653. }
  654. },
  655. loadImage(src, crossOrigin = '') {
  656. return new Promise((resolve, reject) => {
  657. const img = new Image();
  658. if (crossOrigin) {
  659. img.crossOrigin = crossOrigin;
  660. }
  661. img.onload = () => resolve(img);
  662. img.onerror = () => reject(new Error('image load failed'));
  663. img.src = src;
  664. });
  665. },
  666. async fetchImageBlob(url) {
  667. const response = await fetch(url, {
  668. credentials: 'include',
  669. });
  670. if (!response.ok) {
  671. throw new Error('fetch image blob failed');
  672. }
  673. return response.blob();
  674. },
  675. handleImageLoad() {
  676. this.syncImageDisplayRect();
  677. },
  678. /**
  679. * 计算圆角样式
  680. * @param {string} position 位置,top/bottom
  681. * @param {string} direction 方向,left/right
  682. * @returns {Object} 圆角样式对象
  683. */
  684. computedBorderRadius(position, direction) {
  685. const radius = this.background[`${position}_${direction}_radius`];
  686. let borderWidth = {};
  687. if (position === 'top') {
  688. borderWidth['border-bottom-width'] = 0;
  689. } else {
  690. borderWidth['border-top-width'] = 0;
  691. }
  692. if (direction === 'left') {
  693. borderWidth['border-right-width'] = 0;
  694. } else {
  695. borderWidth['border-left-width'] = 0;
  696. }
  697. return {
  698. [`border-${position}-${direction}-radius`]: `${radius}px`,
  699. ...borderWidth,
  700. };
  701. },
  702. uploadFile(file) {
  703. fileUpload('Mid', file, { isGlobalprogress: true })
  704. .then(({ file_info_list }) => {
  705. this.$refs.upload.clearFiles();
  706. if (file_info_list.length > 0) {
  707. const fileUrl = file_info_list[0].file_url_open;
  708. const img = new Image();
  709. img.src = fileUrl;
  710. img.onload = () => {
  711. const { width, height } = img;
  712. this.normalComputed(width, height);
  713. this.file_url = fileUrl;
  714. };
  715. }
  716. })
  717. .catch(() => {
  718. this.$message.error('上传失败');
  719. });
  720. },
  721. /**
  722. * 正常填充
  723. * @param {number} width 图片宽度
  724. * @param {number} height 图片高度
  725. */
  726. normalComputed(width, height) {
  727. if (width > this.maxWidth || height > this.maxHeight) {
  728. const wScale = width / this.maxWidth;
  729. const hScale = height / this.maxHeight;
  730. const scale = wScale > hScale ? this.maxWidth / 2 / width : this.maxHeight / 2 / height;
  731. this.imgData = {
  732. width: width * scale,
  733. height: height * scale,
  734. top: 0,
  735. left: 0,
  736. };
  737. } else {
  738. this.imgData = {
  739. width,
  740. height,
  741. top: 0,
  742. left: 0,
  743. };
  744. }
  745. },
  746. confirm() {
  747. this.$emit(
  748. 'setComponentBackground',
  749. this.file_url,
  750. {
  751. width: (this.imgData.width / this.maxWidth) * 100,
  752. height: (this.imgData.height / this.maxHeight) * 100,
  753. top: (this.imgData.top / this.maxHeight) * 100,
  754. left: (this.imgData.left / this.maxWidth) * 100,
  755. imgX: (this.imgData.left / (this.maxWidth - this.imgData.width)) * 100,
  756. imgY: (this.imgData.top / (this.maxHeight - this.imgData.height)) * 100,
  757. },
  758. this.background,
  759. );
  760. this.handleClose();
  761. },
  762. },
  763. };
  764. </script>
  765. <style lang="scss" scoped>
  766. .background-title {
  767. margin-bottom: 12px;
  768. font-size: 16px;
  769. font-weight: 500;
  770. color: $font-light-color;
  771. }
  772. .set-background {
  773. display: flex;
  774. column-gap: 8px;
  775. .background-img {
  776. position: relative;
  777. border: 1px dashed rgba(0, 0, 0, 8%);
  778. &.is-crop-mode {
  779. cursor: crosshair;
  780. }
  781. .crop-tip {
  782. position: absolute;
  783. top: 8px;
  784. left: 8px;
  785. z-index: 3;
  786. padding: 2px 8px;
  787. font-size: 12px;
  788. color: #fff;
  789. pointer-events: none;
  790. background: rgba(0, 0, 0, 45%);
  791. border-radius: 10px;
  792. }
  793. .crop-selection {
  794. position: absolute;
  795. z-index: 2;
  796. pointer-events: none;
  797. border: 1px dashed #fff;
  798. box-shadow: 0 0 0 9999px rgba(0, 0, 0, 35%);
  799. }
  800. .img-set {
  801. display: inline-grid;
  802. grid-template:
  803. ' . . . ' 2px
  804. ' . img . ' auto
  805. ' . . . ' 2px
  806. / 2px auto 2px;
  807. img {
  808. object-fit: cover;
  809. }
  810. .horizontal-line,
  811. .vertical-line {
  812. background-color: $main-color;
  813. }
  814. .horizontal-line {
  815. width: 100%;
  816. height: 2px;
  817. cursor: ns-resize;
  818. }
  819. .vertical-line {
  820. width: 2px;
  821. height: 100%;
  822. cursor: ew-resize;
  823. }
  824. .dot {
  825. z-index: 1;
  826. width: 6px;
  827. height: 6px;
  828. background-color: $main-color;
  829. &.top-left {
  830. top: -2px;
  831. left: -2px;
  832. }
  833. &.top-right {
  834. top: -2px;
  835. right: 2px;
  836. }
  837. &.bottom-left {
  838. bottom: 2px;
  839. left: -2px;
  840. }
  841. &.bottom-right {
  842. right: 2px;
  843. bottom: 2px;
  844. }
  845. &.top-left,
  846. &.bottom-right {
  847. position: relative;
  848. cursor: nwse-resize;
  849. }
  850. &.top-right,
  851. &.bottom-left {
  852. position: relative;
  853. cursor: nesw-resize;
  854. }
  855. }
  856. }
  857. }
  858. .setup {
  859. display: flex;
  860. flex-direction: column;
  861. row-gap: 12px;
  862. width: 200px;
  863. &-item {
  864. display: flex;
  865. flex-direction: column;
  866. column-gap: 8px;
  867. .setup-top {
  868. margin-bottom: 6px;
  869. }
  870. .setup-content {
  871. display: flex;
  872. flex-direction: column;
  873. row-gap: 8px;
  874. .image-set {
  875. display: flex;
  876. align-items: center;
  877. justify-content: space-around;
  878. height: 32px;
  879. color: $font-light-color;
  880. background-color: #f2f3f5;
  881. border: 1px solid #d9d9d9;
  882. border-radius: 4px;
  883. .opacity-icon {
  884. display: inline-block;
  885. width: 20px;
  886. height: 20px;
  887. vertical-align: middle;
  888. background: url('@/assets/icon/opacity.png');
  889. }
  890. .select-button {
  891. color: #165dff;
  892. cursor: pointer;
  893. background-color: #fff;
  894. box-shadow: 0 0 0 3px #fff;
  895. }
  896. }
  897. .mode-list {
  898. display: flex;
  899. padding: 2px 3px;
  900. background-color: #f2f3f5;
  901. border: 1px solid #d9d9d9;
  902. border-radius: 4px;
  903. span {
  904. flex: 1;
  905. padding: 2px 6px;
  906. line-height: 21px;
  907. text-align: center;
  908. cursor: pointer;
  909. &.active {
  910. color: $main-color;
  911. background-color: #fff;
  912. }
  913. }
  914. }
  915. .crop-actions {
  916. z-index: 3001;
  917. display: flex;
  918. }
  919. &.border-radius {
  920. display: grid;
  921. grid-template-rows: 1fr 1fr;
  922. grid-template-columns: 1fr 1fr;
  923. gap: 8px 8px;
  924. .radius-item {
  925. display: flex;
  926. flex: 1;
  927. column-gap: 4px;
  928. align-items: center;
  929. height: 32px;
  930. padding: 2px 6px;
  931. background-color: #f2f3f5;
  932. .span-radius {
  933. display: inline-block;
  934. width: 16px;
  935. height: 16px;
  936. border-color: #000;
  937. border-style: solid;
  938. border-width: 2px;
  939. }
  940. }
  941. }
  942. }
  943. }
  944. }
  945. }
  946. </style>
  947. <style lang="scss">
  948. .el-dialog.component-background {
  949. .el-dialog__header {
  950. display: none;
  951. }
  952. .el-dialog__body {
  953. padding: 8px 16px;
  954. }
  955. }
  956. </style>