SetComponentBackground.vue 33 KB

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