StepsScroll.vue 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <template>
  2. <div class="steps-scroll-container">
  3. <!-- 左箭头 -->
  4. <div v-if="showArrows" class="nav-arrow left-arrow" :class="{ disabled: isScrollStart }" @click="scrollTo('left')">
  5. <slot name="left-arrow">
  6. <up-icon name="arrow-left" size="20" />
  7. </slot>
  8. </div>
  9. <!-- 步骤滚动区域 -->
  10. <scroll-view :scroll-x="true" :scroll-left="scrollLeft" :scroll-with-animation="true" class="steps-scroll-view"
  11. @scroll="handleScroll" ref="scrollViewRef">
  12. <up-steps class="custom-steps" :current="current" :active-color="activeColor" :inactive-color="inactiveColor"
  13. direction="row">
  14. <up-steps-item class="step-item" v-for="(item, index) in steps" :key="item.id || index"
  15. :title="renderStepTitle(item, index)" :status="getStepStatus(index)" @click="handleStepClick(index)">
  16. <!-- 自定义图标插槽 -->
  17. <template #icon>
  18. <slot name="icon" :item="item" :index="index">
  19. <!-- <up-icon :name="getStepIcon(index)" :size="iconSize" /> -->
  20. </slot>
  21. </template>
  22. <!-- 描述内容插槽 -->
  23. <template #description>
  24. <slot name="description" :item="item" :index="index">
  25. <div v-if="showDescription && index === current" class="step-description">
  26. {{ item.description }}
  27. </div>
  28. </slot>
  29. </template>
  30. </up-steps-item>
  31. </up-steps>
  32. </scroll-view>
  33. <!-- 右箭头 -->
  34. <div v-if="showArrows" class="nav-arrow right-arrow" :class="{ disabled: isScrollEnd }" @click="scrollTo('right')">
  35. <slot name="right-arrow">
  36. <up-icon name="arrow-right" size="20" />
  37. </slot>
  38. </div>
  39. </div>
  40. </template>
  41. <script setup>
  42. import { ref, onMounted, nextTick, computed } from 'vue'
  43. const props = defineProps({
  44. steps: {
  45. type: Array,
  46. required: true,
  47. default: () => []
  48. },
  49. current: {
  50. type: Number,
  51. default: 0
  52. },
  53. activeColor: {
  54. type: String,
  55. default: '#1890ff'
  56. },
  57. inactiveColor: {
  58. type: String,
  59. default: '#d9d9d9'
  60. },
  61. iconSize: {
  62. type: [Number, String],
  63. default: 16
  64. },
  65. maxTitleLength: {
  66. type: Number,
  67. default: 56
  68. },
  69. showDescription: {
  70. type: Boolean,
  71. default: true
  72. },
  73. showArrows: {
  74. type: Boolean,
  75. default: false
  76. },
  77. scrollStep: {
  78. type: Number,
  79. default: 200
  80. }
  81. })
  82. const emit = defineEmits(['step-click'])
  83. const scrollViewRef = ref(null)
  84. const scrollLeft = ref(0)
  85. const maxScrollLeft = ref(0)
  86. const isScrollStart = ref(true)
  87. const isScrollEnd = ref(false)
  88. // 渲染步骤标题(带序号和省略)
  89. const renderStepTitle = (item, index) => {
  90. const title = item.name || item.title || `${index + 1}`
  91. const maxLen = props.maxTitleLength
  92. return `${title.length > maxLen ? title.substring(0, maxLen) + '...' : title}`
  93. }
  94. // 获取步骤状态
  95. const getStepStatus = (index) => {
  96. if (index < props.current) return 'finish'
  97. if (index === props.current) return 'process'
  98. return 'wait'
  99. }
  100. // 获取步骤图标
  101. const getStepIcon = (index) => {
  102. const defaultIcons = ['check-circle', 'edit-pen', 'setting', 'user', 'more']
  103. return props.steps[index]?.icon || defaultIcons[index % defaultIcons.length]
  104. }
  105. // 处理步骤点击
  106. const handleStepClick = (index) => {
  107. emit('step-click', { index, step: props.steps[index] })
  108. }
  109. // 处理滚动事件
  110. const handleScroll = (e) => {
  111. scrollLeft.value = e.detail.scrollLeft
  112. isScrollStart.value = scrollLeft.value <= 0
  113. isScrollEnd.value = scrollLeft.value >= maxScrollLeft.value - 10
  114. }
  115. // 滚动到指定方向
  116. const scrollTo = (direction) => {
  117. if ((direction === 'left' && isScrollStart.value)) return
  118. if ((direction === 'right' && isScrollEnd.value)) return
  119. let newScrollLeft = scrollLeft.value
  120. if (direction === 'left') {
  121. newScrollLeft = Math.max(0, scrollLeft.value - props.scrollStep)
  122. } else {
  123. newScrollLeft = Math.min(maxScrollLeft.value, scrollLeft.value + props.scrollStep)
  124. }
  125. scrollLeft.value = newScrollLeft
  126. }
  127. // 计算最大滚动距离
  128. const calculateMaxScroll = async () => {
  129. await nextTick()
  130. if (!scrollViewRef.value) return
  131. const query = uni.createSelectorQuery().in(scrollViewRef.value)
  132. query.select('.custom-steps').boundingClientRect(data => {
  133. const stepsWidth = data?.width || 0
  134. query.select('.steps-scroll-view').boundingClientRect(data => {
  135. const containerWidth = data?.width || 0
  136. maxScrollLeft.value = stepsWidth - containerWidth
  137. isScrollEnd.value = maxScrollLeft.value <= 0
  138. }).exec()
  139. }).exec()
  140. }
  141. // 暴露方法
  142. defineExpose({
  143. scrollTo,
  144. calculateMaxScroll
  145. })
  146. onMounted(() => {
  147. calculateMaxScroll()
  148. // 监听窗口变化重新计算
  149. uni.onWindowResize(() => {
  150. calculateMaxScroll()
  151. })
  152. })
  153. </script>
  154. <style lang="less" scoped>
  155. .steps-scroll-container {
  156. position: relative;
  157. width: 100%;
  158. display: flex;
  159. align-items: center;
  160. padding: 0 36px; // 为箭头留出空间
  161. box-sizing: border-box;
  162. .steps-scroll-view {
  163. flex: 1;
  164. width: 100%;
  165. white-space: nowrap;
  166. padding: 12px 0;
  167. scroll-behavior: smooth;
  168. .custom-steps {
  169. display: inline-flex;
  170. min-width: 100%;
  171. .step-item {
  172. min-width: 120px;
  173. max-width: 180px;
  174. padding: 0 12px;
  175. box-sizing: border-box;
  176. white-space: normal;
  177. word-break: break-word;
  178. cursor: pointer;
  179. transition: all 0.3s;
  180. &:hover {
  181. transform: translateY(-2px);
  182. }
  183. &:deep(.up-steps-item-title) {
  184. font-size: 14px;
  185. line-height: 1.4;
  186. color: #333;
  187. font-weight: 500;
  188. display: -webkit-box;
  189. -webkit-line-clamp: 2;
  190. -webkit-box-orient: vertical;
  191. overflow: hidden;
  192. text-overflow: ellipsis;
  193. }
  194. &:deep(.up-steps-item-description) {
  195. font-size: 12px;
  196. color: #666;
  197. margin-top: 4px;
  198. display: -webkit-box;
  199. -webkit-line-clamp: 2;
  200. -webkit-box-orient: vertical;
  201. overflow: hidden;
  202. text-overflow: ellipsis;
  203. }
  204. }
  205. }
  206. }
  207. .nav-arrow {
  208. position: absolute;
  209. top: 50%;
  210. transform: translateY(-50%);
  211. width: 32px;
  212. height: 32px;
  213. display: flex;
  214. align-items: center;
  215. justify-content: center;
  216. background: #fff;
  217. border-radius: 50%;
  218. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  219. color: v-bind('props.activeColor');
  220. cursor: pointer;
  221. transition: all 0.3s;
  222. z-index: 1;
  223. &:hover {
  224. background: #f0f7ff;
  225. }
  226. &.disabled {
  227. color: #ccc;
  228. cursor: not-allowed;
  229. background: #f5f5f5;
  230. }
  231. &.left-arrow {
  232. left: 0;
  233. }
  234. &.right-arrow {
  235. right: 0;
  236. }
  237. }
  238. }
  239. </style>