SmartDialog.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. <template>
  2. <div v-show="visible" class="smart-dialog" :style="dialogStyle" @mousedown="onRootMousedown" @dblclick="handleDoubleClick" :class="{ 'no-padding': noPadding, 'is-pending-drag': isPendingDrag, 'is-dragging': isDragging }">
  3. <div class="dialog-header" :class="{ 'is-draggable': draggable }" @mousedown="startDrag" v-if="title">
  4. <div class="title-content">
  5. <slot name="header">
  6. <span class="title">{{ title }}</span>
  7. </slot>
  8. </div>
  9. <span v-if="showClose" class="close-btn" @click.stop="close">✕</span>
  10. </div>
  11. <div v-else class="dialog-header not-title" :class="{ 'is-draggable': draggable }" @mousedown="startDrag"></div>
  12. <div v-if="title" class="dialog-divider"></div>
  13. <div class="dialog-body" :class="{ 'no-padding': noPadding }" @mousedown="onBodyMousedown">
  14. <slot></slot>
  15. </div>
  16. <div v-if="resizable" class="resize-handle" @mousedown.prevent="startResize"></div>
  17. </div>
  18. </template>
  19. <script>
  20. let globalZIndex = 2000;
  21. const DESIGN_WIDTH = 1920;
  22. export default {
  23. name: 'SmartDialog',
  24. props: {
  25. id: { type: [String, Number], required: true },
  26. visible: { type: Boolean, default: false },
  27. title: { type: String, default: '提示' },
  28. center: { type: Boolean, default: true },
  29. position: { type: Object, default: () => ({ x: 0, y: 0 }) },
  30. showClose: { type: Boolean, default: true },
  31. draggable: { type: Boolean, default: true },
  32. resizable: { type: Boolean, default: true },
  33. noPadding: { type: Boolean, default: false },
  34. defaultWidth: { type: [Number, String], default: 350 },
  35. defaultHeight: { type: [Number, String], default: 250 },
  36. minWidth: { type: Number, default: 200 },
  37. minHeight: { type: Number, default: 150 },
  38. enableDblclickExpand: { type: Boolean, default: false },
  39. bringToFrontOnMousedown: { type: Boolean, default: true },
  40. },
  41. data() {
  42. return {
  43. x: 0,
  44. y: 0,
  45. w: 0,
  46. h: 0,
  47. currentScale: 1,
  48. zIndex: globalZIndex,
  49. isDragging: false,
  50. isPendingDrag: false,
  51. isResizing: false,
  52. dragOffset: { x: 0, y: 0 },
  53. resizeStart: { x: 0, y: 0, w: 0, h: 0 }
  54. };
  55. },
  56. computed: {
  57. dialogStyle() {
  58. let cursor;
  59. if (this.isDragging || this.isPendingDrag) cursor = 'grabbing';
  60. else if (this.enableDblclickExpand) cursor = 'pointer';
  61. else cursor = 'default';
  62. return {
  63. left: `${this.x}px`,
  64. top: `${this.y}px`,
  65. width: `${this.w}px`,
  66. height: `${this.h}px`,
  67. zIndex: this.zIndex,
  68. cursor,
  69. };
  70. }
  71. },
  72. created() {
  73. this.currentScale = this.getRealScale();
  74. this.w = this._parseSize(this.defaultWidth, window.innerWidth);
  75. this.h = this._parseSize(this.defaultHeight, window.innerHeight);
  76. this._lastWinW = window.innerWidth;
  77. this._lastWinH = window.innerHeight;
  78. },
  79. mounted() {
  80. window.addEventListener('resize', this._onWindowResize);
  81. if (this.visible) {
  82. this.bringToFront();
  83. this.calculatePosition();
  84. }
  85. },
  86. watch: {
  87. visible(newVal) {
  88. if (newVal) {
  89. this.bringToFront();
  90. this.calculatePosition();
  91. }
  92. },
  93. position: {
  94. deep: true,
  95. handler() {
  96. if (this.visible && !this.center) {
  97. this.calculatePosition();
  98. }
  99. }
  100. },
  101. defaultWidth(val) {
  102. this.w = this._parseSize(val, window.innerWidth);
  103. },
  104. defaultHeight(val) {
  105. this.h = this._parseSize(val, window.innerHeight);
  106. }
  107. },
  108. methods: {
  109. handleDoubleClick(e) {
  110. if (!this.enableDblclickExpand) return;
  111. if (e.target.classList.contains('close-btn') || e.target.classList.contains('resize-handle')) {
  112. return;
  113. }
  114. this.$emit('expand', this.id);
  115. },
  116. getRealScale() {
  117. return window.innerWidth / DESIGN_WIDTH;
  118. },
  119. _parseSize(value, base) {
  120. if (typeof value === 'string' && value.endsWith('%')) {
  121. return Math.round((parseFloat(value) / 100) * base);
  122. }
  123. const scale = this.getRealScale();
  124. return Number(value) * scale;
  125. },
  126. _onWindowResize() {
  127. setTimeout(() => {
  128. if (window.innerWidth === this._lastWinW && window.innerHeight === this._lastWinH) {
  129. return;
  130. }
  131. this._lastWinW = window.innerWidth;
  132. this._lastWinH = window.innerHeight;
  133. const newScale = this.getRealScale();
  134. const scaleRatio = newScale / this.currentScale;
  135. this.w = this._parseSize(this.defaultWidth, window.innerWidth);
  136. this.h = this._parseSize(this.defaultHeight, window.innerHeight);
  137. if (this.visible) {
  138. this.x = this.x * scaleRatio;
  139. this.y = this.y * scaleRatio;
  140. if (this.x + this.w > window.innerWidth) this.x = window.innerWidth - this.w;
  141. if (this.y + this.h > window.innerHeight) this.y = window.innerHeight - this.h;
  142. if (this.x < 0) this.x = 0;
  143. if (this.y < 0) this.y = 0;
  144. }
  145. this.currentScale = newScale;
  146. }, 50);
  147. },
  148. close() {
  149. this.$emit('update:visible', false);
  150. this.$emit('close');
  151. },
  152. bringToFront() {
  153. globalZIndex++;
  154. this.zIndex = globalZIndex;
  155. },
  156. onRootMousedown() {
  157. if (this.bringToFrontOnMousedown) this.bringToFront();
  158. },
  159. onBodyMousedown(e) {
  160. if (!this.draggable) return;
  161. const EXEMPT = 'button, input, select, textarea, a, [contenteditable], [tabindex],'
  162. + ' .drag-handle, .resize-handle, .close-btn, [data-no-drag]';
  163. if (e.target.closest(EXEMPT)) return;
  164. const startX = e.clientX, startY = e.clientY;
  165. const THRESHOLD = 5;
  166. const HINT_DELAY = 100;
  167. let dragStarted = false;
  168. const hintTimer = setTimeout(() => {
  169. if (!dragStarted) this.isPendingDrag = true;
  170. }, HINT_DELAY);
  171. const onMove = (ev) => {
  172. if (dragStarted) return;
  173. const dx = Math.abs(ev.clientX - startX);
  174. const dy = Math.abs(ev.clientY - startY);
  175. if (dx > THRESHOLD || dy > THRESHOLD) {
  176. dragStarted = true;
  177. clearTimeout(hintTimer);
  178. this.isPendingDrag = false;
  179. this.startDrag({ clientX: startX, clientY: startY, target: e.target });
  180. }
  181. };
  182. const onUp = () => {
  183. clearTimeout(hintTimer);
  184. this.isPendingDrag = false;
  185. document.removeEventListener('mousemove', onMove);
  186. document.removeEventListener('mouseup', onUp);
  187. };
  188. document.addEventListener('mousemove', onMove);
  189. document.addEventListener('mouseup', onUp);
  190. },
  191. calculatePosition() {
  192. this.$nextTick(() => {
  193. const winWidth = window.innerWidth;
  194. const winHeight = window.innerHeight;
  195. const scale = this.getRealScale();
  196. let targetX = 0;
  197. let targetY = 0;
  198. if (this.center) {
  199. targetX = Math.max(0, (winWidth - this.w) / 2);
  200. targetY = Math.max(0, (winHeight - this.h) / 2);
  201. } else {
  202. targetX = (this.position.x || 0) * scale;
  203. targetY = (this.position.y || 0) * scale;
  204. }
  205. const offsetStep = 20 * scale;
  206. let collision = true;
  207. let attempts = 0;
  208. const existingDialogs = document.querySelectorAll('.smart-dialog');
  209. while (collision && attempts < 15) {
  210. collision = false;
  211. for (let i = 0; i < existingDialogs.length; i++) {
  212. const el = existingDialogs[i];
  213. if (el === this.$el || el.style.display === 'none') continue;
  214. const rect = el.getBoundingClientRect();
  215. if (Math.abs(rect.left - targetX) < 2 && Math.abs(rect.top - targetY) < 2) {
  216. collision = true;
  217. break;
  218. }
  219. }
  220. if (collision) {
  221. targetX += offsetStep;
  222. targetY += offsetStep;
  223. attempts++;
  224. }
  225. }
  226. if (targetX + this.w > winWidth) targetX = winWidth - this.w - 10;
  227. if (targetY + this.h > winHeight) targetY = winHeight - this.h - 10;
  228. this.x = Math.max(0, targetX);
  229. this.y = Math.max(0, targetY);
  230. });
  231. },
  232. startDrag(e) {
  233. if (!this.draggable || e.target.classList.contains('close-btn')) return;
  234. this.isDragging = true;
  235. this.dragOffset.x = e.clientX - this.x;
  236. this.dragOffset.y = e.clientY - this.y;
  237. document.addEventListener('mousemove', this.onDrag);
  238. document.addEventListener('mouseup', this.stopDrag);
  239. },
  240. onDrag(e) {
  241. if (!this.isDragging) return;
  242. let newX = e.clientX - this.dragOffset.x;
  243. let newY = e.clientY - this.dragOffset.y;
  244. const scale = this.getRealScale();
  245. const safeHeaderHeight = 40 * scale;
  246. this.x = Math.max(0, Math.min(newX, window.innerWidth - this.w));
  247. this.y = Math.max(0, Math.min(newY, window.innerHeight - safeHeaderHeight));
  248. },
  249. stopDrag() {
  250. this.isDragging = false;
  251. document.removeEventListener('mousemove', this.onDrag);
  252. document.removeEventListener('mouseup', this.stopDrag);
  253. },
  254. startResize(e) {
  255. if (!this.resizable) return;
  256. this.isResizing = true;
  257. this.resizeStart = { x: e.clientX, y: e.clientY, w: this.w, h: this.h };
  258. document.addEventListener('mousemove', this.onResize);
  259. document.addEventListener('mouseup', this.stopResize);
  260. },
  261. onResize(e) {
  262. if (!this.isResizing) return;
  263. const deltaX = e.clientX - this.resizeStart.x;
  264. const deltaY = e.clientY - this.resizeStart.y;
  265. const scale = this.getRealScale();
  266. const currentMinWidth = this.minWidth * scale;
  267. const currentMinHeight = this.minHeight * scale;
  268. this.w = Math.max(currentMinWidth, this.resizeStart.w + deltaX);
  269. this.h = Math.max(currentMinHeight, this.resizeStart.h + deltaY);
  270. },
  271. stopResize() {
  272. this.isResizing = false;
  273. document.removeEventListener('mousemove', this.onResize);
  274. document.removeEventListener('mouseup', this.stopResize);
  275. }
  276. },
  277. beforeDestroy() {
  278. window.removeEventListener('resize', this._onWindowResize);
  279. document.removeEventListener('mousemove', this.onDrag);
  280. document.removeEventListener('mouseup', this.stopDrag);
  281. document.removeEventListener('mousemove', this.onResize);
  282. document.removeEventListener('mouseup', this.stopResize);
  283. }
  284. };
  285. </script>
  286. <style scoped>
  287. .smart-dialog.is-pending-drag,
  288. .smart-dialog.is-pending-drag *,
  289. .smart-dialog.is-dragging,
  290. .smart-dialog.is-dragging * {
  291. cursor: grabbing !important;
  292. }
  293. .smart-dialog {
  294. position: fixed;
  295. background: radial-gradient(circle at 20% 0%, rgba(40,120,200,0.2) 0%, rgba(20,60,130,0.4) 70%);
  296. box-shadow: inset 0px 0px 0.625rem 0px rgba(88, 146, 255, 0.4), inset 1.25rem 0px 1.875rem -0.625rem rgba(88, 146, 255, 0.15);
  297. border: 1px solid rgba(255, 255, 255, 0.15);
  298. border-radius: 12px;
  299. backdrop-filter: blur(8px);
  300. -webkit-backdrop-filter: blur(8px);
  301. display: flex;
  302. flex-direction: column;
  303. overflow: hidden;
  304. user-select: none;
  305. padding: clamp(2px, 0.6cqw, 6px);
  306. container-type: inline-size;
  307. }
  308. .smart-dialog.no-padding {
  309. padding: 0;
  310. }
  311. .dialog-header {
  312. height: auto;
  313. background: transparent;
  314. display: flex;
  315. justify-content: space-between;
  316. align-items: center;
  317. padding: clamp(10px, 1.2cqw, 12px) clamp(10px, 1.2cqw, 12px);
  318. }
  319. .dialog-header.not-title {
  320. padding: 0;
  321. }
  322. .dialog-header.is-draggable {
  323. cursor: move;
  324. }
  325. .not-title.is-draggable {
  326. height: 10px;
  327. }
  328. .title-content {
  329. flex: 1;
  330. min-width: 0;
  331. }
  332. .title {
  333. color: #ffffff;
  334. font-size: clamp(14px, 2cqw, 18px);
  335. font-weight: 500;
  336. letter-spacing: 1px;
  337. }
  338. .close-btn {
  339. cursor: pointer;
  340. color: #ffffff;
  341. font-size: clamp(11px, 1.8cqw, 16px);
  342. line-height: 1;
  343. font-weight: 300;
  344. opacity: 0.8;
  345. transition: all 0.2s;
  346. margin-left:clamp(10px, 2cqw, 15px);
  347. }
  348. .close-btn:hover {
  349. opacity: 1;
  350. transform: scale(1.1);
  351. }
  352. .dialog-divider {
  353. height: 1px;
  354. background-color: rgba(255, 255, 255, 0.3);
  355. margin: 0 clamp(2px, 0.6cqw, 6px);
  356. }
  357. .dialog-body {
  358. flex: 1;
  359. min-height: 0;
  360. padding: clamp(6px, 2.2cqw, 20px);
  361. overflow: hidden;
  362. display: flex;
  363. flex-direction: column;
  364. }
  365. .dialog-body.no-padding {
  366. padding: 0;
  367. }
  368. .dialog-body.no-padding {
  369. padding: 0;
  370. color: #e2e8f0;
  371. overflow: hidden;
  372. cursor: default;
  373. }
  374. .resize-handle {
  375. position: absolute;
  376. right: 0;
  377. bottom: 0;
  378. width: 16px;
  379. height: 16px;
  380. cursor: se-resize;
  381. background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.2) 50%);
  382. }
  383. </style>