channel.vue 12 KB

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