SetComponentBackground.vue 32 KB

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