channel.vue 14 KB

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