channel.vue 16 KB

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