channel.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. <template>
  2. <u-popup :show="show" round="40rpx" @close="handleClose">
  3. <view class="channel-container">
  4. <!-- 我的频道区域 -->
  5. <view class="channel-section">
  6. <view class="section-header">
  7. <view class="left">
  8. <text class="section-title">我的频道</text>
  9. <text class="txt">拖拽可以排序</text>
  10. </view>
  11. <image class="w40 h40" src="/static/images/pop_close_icon.png" @click="show=false"></image>
  12. </view>
  13. <!-- 拖拽容器 -->
  14. <view class="channel-list" id="channelList">
  15. <view v-for="(item, index) in myChannels" :key="item.id" class="channel-item"
  16. :style="{ width: itemWidth + 'px' }"
  17. @touchstart="handleTouchStart(index, $event)"
  18. @touchmove="(e) => handleMove(e, index)"
  19. @touchend="handleEnd"
  20. @touchcancel="handleEnd">
  21. <!-- 拖拽中的元素特殊样式 -->
  22. <view class="channel-content" :class="{
  23. 'drag-item': isDragging && dragIndex === index
  24. }" :style="{
  25. transform: isDragging && dragIndex === index
  26. ? `translate(${dragOffsetX}px, ${dragOffsetY}px) scale(1.05)`
  27. : 'none',
  28. zIndex: isDragging && dragIndex === index ? 999 : 1,
  29. opacity: isDragging && dragIndex === index ? 0.9 : 1,
  30. transition: isDragging && dragIndex === index ? 'none' : 'all 0.15s ease-out'
  31. }" @tap="handleChannelTap(item)">
  32. <text>{{ item.name }}</text>
  33. <!-- 保留删除图标 -->
  34. <image v-if="index !== 0" class="delete-icon" src="/static/images/remove_icon.png"
  35. @tap.stop="removeFromMyChannels(index)">
  36. </view>
  37. <!-- 拖拽占位符-->
  38. <!-- <view v-if="isDragging && placeholderIndex === index" class="channel-placeholder"></view> -->
  39. </view>
  40. </view>
  41. </view>
  42. <!-- 全部频道区域 -->
  43. <view class="channel-section">
  44. <view class="section-header">
  45. <text class="section-title">全部频道</text>
  46. </view>
  47. <view class="channel-list">
  48. <view v-for="(item, index) in allChannels" :key="item.id" class="channel-item"
  49. :style="{ width: itemWidth + 'px' }">
  50. <view class="channel-content" :class="isChannelInMyList(item.id)?'active':''">
  51. <text>{{ item.name }}</text>
  52. <!-- 保留添加图标 -->
  53. <image v-if="!isChannelInMyList(item.id)" class="add-icon" src="/static/images/add_icon.png"
  54. @tap="addToMyChannels(item)">
  55. </image>
  56. </view>
  57. </view>
  58. </view>
  59. </view>
  60. <view class="success">完成</view>
  61. </view>
  62. </u-popup>
  63. </template>
  64. <script>
  65. export default {
  66. props: {
  67. initialMyChannels: {
  68. type: Array,
  69. default: () => []
  70. },
  71. initialAllChannels: {
  72. type: Array,
  73. default: () => []
  74. },
  75. activeChannelId: {
  76. type: [String, Number],
  77. default: ''
  78. },
  79. itemWidth: {
  80. type: Number,
  81. default: 80
  82. }
  83. },
  84. data() {
  85. return {
  86. show: true,
  87. myChannels: [],
  88. allChannels: [],
  89. activeChannel: '',
  90. isDragging: false,
  91. dragIndex: -1, // 正在拖拽的索引
  92. placeholderIndex: -1, // 占位符索引
  93. startX: 0, // 触摸初始X
  94. startY: 0, // 触摸初始Y
  95. elementLeft: 0, // 元素初始左坐标
  96. elementTop: 0, // 元素初始上坐标
  97. dragOffsetX: 0, // 拖拽偏移X
  98. dragOffsetY: 0, // 拖拽偏移Y
  99. itemHeight: 60, // 频道项高度
  100. colCount: 0, // 每行显示列数
  101. itemRects: [], // 所有频道项的位置信息
  102. dragThreshold: 3, // 降低触发阈值,更灵敏
  103. lastSwapTime: 0, // 防抖:记录上次交换时间
  104. touchStartTime: 0, // 触摸开始时间
  105. rectTimer: null // 计算位置的定时器
  106. };
  107. },
  108. watch: {
  109. activeChannelId: {
  110. immediate: true,
  111. handler(newVal) {
  112. this.activeChannel = newVal;
  113. }
  114. },
  115. initialMyChannels: {
  116. immediate: true,
  117. handler(newVal) {
  118. this.myChannels = JSON.parse(JSON.stringify(newVal));
  119. this.$nextTick(() => this.calcItemRects());
  120. }
  121. },
  122. initialAllChannels: {
  123. immediate: true,
  124. handler(newVal) {
  125. this.allChannels = JSON.parse(JSON.stringify(newVal));
  126. }
  127. }
  128. },
  129. mounted() {
  130. this.$nextTick(() => this.calcItemRects());
  131. },
  132. methods: {
  133. // 计算所有频道项的位置信息(保留原有逻辑,优化计算精度)
  134. calcItemRects() {
  135. if (!this.myChannels.length) return;
  136. const query = uni.createSelectorQuery().in(this);
  137. query.select('#channelList').boundingClientRect(rect => {
  138. if (!rect) return;
  139. // 计算每行列数(考虑容器内边距)
  140. this.colCount = Math.floor((rect.width - 40) / this.itemWidth);
  141. // 预计算每个项的位置
  142. this.itemRects = this.myChannels.map((_, index) => {
  143. const row = Math.floor(index / this.colCount);
  144. const col = index % this.colCount;
  145. return {
  146. left: rect.left + 20 + col * this.itemWidth,
  147. top: rect.top + 20 + row * this.itemHeight,
  148. right: rect.left + 20 + (col + 1) * this.itemWidth,
  149. bottom: rect.top + 20 + (row + 1) * this.itemHeight,
  150. centerX: rect.left + 20 + (col + 0.5) * this.itemWidth,
  151. centerY: rect.top + 20 + (row + 0.5) * this.itemHeight
  152. };
  153. });
  154. }).exec();
  155. },
  156. // 判断频道是否已在我的列表中(保留原有功能)
  157. isChannelInMyList(channelId) {
  158. return this.myChannels.some(item => item.id === channelId);
  159. },
  160. // 添加频道到我的频道(保留原有功能)
  161. addToMyChannels(channel) {
  162. if (this.isChannelInMyList(channel.id)) return;
  163. this.myChannels = [...this.myChannels, channel];
  164. this.$nextTick(() => this.calcItemRects());
  165. this.$emit('channels-change', {
  166. myChannels: this.myChannels,
  167. allChannels: this.allChannels
  168. });
  169. },
  170. // 从我的频道中移除(保留原有功能)
  171. removeFromMyChannels(index) {
  172. if (index === 0 || this.isDragging) return;
  173. this.myChannels.splice(index, 1);
  174. this.$nextTick(() => this.calcItemRects());
  175. this.$emit('channels-change', {
  176. myChannels: this.myChannels,
  177. allChannels: this.allChannels
  178. });
  179. },
  180. // 点击频道(保留原有功能)
  181. handleChannelTap(channel) {
  182. if (this.isDragging) return;
  183. this.activeChannel = channel.id;
  184. this.$emit('channel-tap', channel);
  185. },
  186. // ========== 以下是拖拽功能的核心优化 ==========
  187. // 触摸开始:记录初始信息,替代原longpress
  188. handleTouchStart(index, e) {
  189. // 第一个频道不能拖拽
  190. if (index === 0) return;
  191. this.touchStartTime = Date.now();
  192. const touch = e.touches[0];
  193. this.startX = touch.clientX;
  194. this.startY = touch.clientY;
  195. // 获取当前元素的初始位置
  196. const query = uni.createSelectorQuery().in(this);
  197. query.selectAll('.channel-item').boundingClientRect(rects => {
  198. const currentRect = rects[index];
  199. if (currentRect) {
  200. this.elementLeft = currentRect.left;
  201. this.elementTop = currentRect.top;
  202. }
  203. }).exec();
  204. },
  205. // 拖拽移动:优化跟随流畅度和交换逻辑
  206. handleMove(e, index) {
  207. if (index === 0) return;
  208. // 阻止页面滚动和默认行为
  209. e.stopPropagation();
  210. if (e.preventDefault) e.preventDefault();
  211. else e.returnValue = false;
  212. const touch = e.touches[0];
  213. const currentX = touch.clientX;
  214. const currentY = touch.clientY;
  215. // 未进入拖拽状态:判断是否触发拖拽(移动距离+触摸时间)
  216. if (!this.isDragging) {
  217. const moveDistance = Math.hypot(currentX - this.startX, currentY - this.startY);
  218. const touchTime = Date.now() - this.touchStartTime;
  219. // 移动超过阈值+触摸时间>80ms,触发拖拽(避免误触)
  220. if (moveDistance >= this.dragThreshold && touchTime > 80) {
  221. this.isDragging = true;
  222. this.dragIndex = index;
  223. this.placeholderIndex = index;
  224. this.lastSwapTime = Date.now();
  225. // 实时计算位置
  226. this.calcItemRects();
  227. } else {
  228. return;
  229. }
  230. }
  231. // 计算偏移量:基于元素初始位置,跟随更丝滑
  232. this.dragOffsetX = currentX - this.elementLeft;
  233. this.dragOffsetY = currentY - this.elementTop;
  234. // 防抖交换:15ms内只交换一次,避免卡顿
  235. const now = Date.now();
  236. if (now - this.lastSwapTime > 15) {
  237. this.swapChannelItem(currentX, currentY);
  238. this.lastSwapTime = now;
  239. }
  240. },
  241. // 交换频道项:优化碰撞检测,精准交换
  242. swapChannelItem(x, y) {
  243. if (this.itemRects.length === 0) return;
  244. // 找到当前触摸点对应的目标项
  245. let targetIndex = -1;
  246. for (let i = 1; i < this.myChannels.length; i++) {
  247. const rect = this.itemRects[i];
  248. // 扩大检测范围,更容易触发交换
  249. if (x >= rect.left - 5 && x <= rect.right + 5 && y >= rect.top - 5 && y <= rect.bottom + 5) {
  250. targetIndex = i;
  251. break;
  252. }
  253. }
  254. // 交换逻辑:只在目标不同时执行
  255. if (targetIndex !== -1 && targetIndex !== this.placeholderIndex) {
  256. // 交换数组元素
  257. [this.myChannels[this.dragIndex], this.myChannels[targetIndex]] = [this.myChannels[targetIndex], this.myChannels[this.dragIndex]];
  258. // 更新占位符和拖拽索引
  259. this.placeholderIndex = targetIndex;
  260. this.dragIndex = targetIndex;
  261. // 更新元素初始位置,让拖拽跟随更准确
  262. this.elementLeft = this.itemRects[targetIndex].left;
  263. this.elementTop = this.itemRects[targetIndex].top;
  264. // 延迟计算位置,避免频繁DOM查询
  265. if (!this.rectTimer) {
  266. this.rectTimer = setTimeout(() => {
  267. this.calcItemRects();
  268. clearTimeout(this.rectTimer);
  269. this.rectTimer = null;
  270. }, 30);
  271. }
  272. }
  273. },
  274. // 拖拽结束/取消:重置状态(保留原有逻辑,优化定时器清理)
  275. handleEnd() {
  276. if (!this.isDragging) return;
  277. // 清理定时器
  278. if (this.rectTimer) {
  279. clearTimeout(this.rectTimer);
  280. this.rectTimer = null;
  281. }
  282. // 重置拖拽状态
  283. this.isDragging = false;
  284. this.dragIndex = -1;
  285. this.placeholderIndex = -1;
  286. this.dragOffsetX = 0;
  287. this.dragOffsetY = 0;
  288. this.startX = 0;
  289. this.startY = 0;
  290. this.elementLeft = 0;
  291. this.elementTop = 0;
  292. // 保存顺序
  293. this.saveChannelOrder();
  294. },
  295. // 保存频道顺序(保留原有功能)
  296. saveChannelOrder() {
  297. this.$emit('order-change', JSON.parse(JSON.stringify(this.myChannels)));
  298. this.$emit('channels-change', {
  299. myChannels: this.myChannels,
  300. allChannels: this.allChannels
  301. });
  302. },
  303. // 关闭弹窗(保留原有功能)
  304. handleClose() {
  305. this.show = false;
  306. this.$emit('close');
  307. }
  308. }
  309. };
  310. </script>
  311. <style scoped lang="scss">
  312. // 保留你所有的样式,仅优化拖拽相关的样式
  313. .channel-container {
  314. border-radius: 32rpx 32rpx 0rpx 0rpx;
  315. padding: 24rpx;
  316. background-color: #fff;
  317. .channel-section {
  318. margin-bottom: 40rpx;
  319. border-radius: 10rpx;
  320. overflow: hidden;
  321. .section-header {
  322. display: flex;
  323. align-items: center;
  324. justify-content: space-between;
  325. height: 80rpx;
  326. margin-bottom: 20rpx;
  327. .left {
  328. .section-title {
  329. font-weight: 500;
  330. font-size: 32rpx;
  331. color: #333333;
  332. }
  333. .txt {
  334. font-size: 24rpx;
  335. color: #999999;
  336. margin-left: 18rpx;
  337. }
  338. }
  339. .txt {
  340. font-size: 24rpx;
  341. color: #999;
  342. }
  343. }
  344. .channel-list {
  345. display: flex;
  346. flex-wrap: wrap;
  347. position: relative;
  348. .channel-item {
  349. position: relative;
  350. box-sizing: border-box;
  351. margin-bottom: 10rpx;
  352. margin-right: 18rpx;
  353. &:last-child {
  354. margin-right: 0;
  355. }
  356. .channel-content {
  357. width: 162rpx;
  358. height: 88rpx;
  359. background: #F5F7FA;
  360. border-radius: 12rpx 12rpx 12rpx 12rpx;
  361. position: relative;
  362. display: flex;
  363. align-items: center;
  364. justify-content: center;
  365. transition: all 0.15s ease-out;
  366. color: #333333;
  367. &.active {
  368. color: #999999;
  369. }
  370. // 拖拽中的元素 - 优化阴影和透明度
  371. &.drag-item {
  372. position: relative;
  373. // 增强阴影效果:增大阴影范围、提高不透明度,让阴影更明显
  374. box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15);
  375. // 降低透明度,让拖拽元素有半透明效果(0.8比0.9更明显)
  376. opacity: 0.8;
  377. background-color: #fff; // 增加白色背景,让半透明效果更自然
  378. }
  379. // 图标样式 - 完全保留
  380. .add-icon,
  381. .delete-icon,
  382. .added-mark {
  383. position: absolute;
  384. right: -10rpx;
  385. top: -10rpx;
  386. width: 32rpx;
  387. height: 32rpx;
  388. border-radius: 50%;
  389. display: flex;
  390. align-items: center;
  391. justify-content: center;
  392. z-index: 2;
  393. }
  394. .add-icon {
  395. .icon-plus {
  396. font-size: 30rpx;
  397. color: #fff;
  398. line-height: 1;
  399. }
  400. }
  401. .delete-icon {
  402. .icon-minus {
  403. font-size: 36rpx;
  404. color: #fff;
  405. line-height: 1;
  406. margin-top: -2rpx;
  407. }
  408. }
  409. .added-mark {
  410. background-color: #d9d9d9;
  411. color: #999;
  412. .icon-check {
  413. font-size: 26rpx;
  414. color: #999;
  415. line-height: 1;
  416. }
  417. }
  418. }
  419. // 拖拽占位符 - 优化背景色,更贴近UI
  420. // .channel-placeholder {
  421. // width: 100%;
  422. // height: 60rpx;
  423. // border-radius: 30rpx;
  424. // box-sizing: border-box;
  425. // background-color: #E8EBF0;
  426. // }
  427. }
  428. }
  429. }
  430. .success {
  431. width: 100%;
  432. height: 88rpx;
  433. line-height: 88rpx;
  434. background: linear-gradient(136deg, #38D97D 0%, #02B176 100%);
  435. border-radius: 44rpx 44rpx 44rpx 44rpx;
  436. font-weight: 600;
  437. font-size: 32rpx;
  438. color: #FFFFFF;
  439. text-align: center;
  440. }
  441. }
  442. .w40 {
  443. width: 40rpx;
  444. }
  445. .h40 {
  446. height: 40rpx;
  447. }
  448. </style>