SetBackground.vue 32 KB

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