StockListItem.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. <template>
  2. <view class="stock-item-wrapper">
  3. <!-- 可滑动的内容区域 -->
  4. <movable-area class="movable-area">
  5. <movable-view
  6. class="movable-view"
  7. direction="horizontal"
  8. :x="moveX"
  9. :damping="40"
  10. :friction="5"
  11. :out-of-bounds="false"
  12. @change="handleMoveChange"
  13. @touchend="handleMoveEnd"
  14. >
  15. <view class="stock-list-item">
  16. <!-- 左侧:股票信息 -->
  17. <view class="stock-left">
  18. <view class="stock-name-row">
  19. <text class="stock-name">{{ stock.name }}</text>
  20. <text :class="['stock-tag', getMarketClass(stock.code)]">{{ getMarketTag(stock.code) }}</text>
  21. </view>
  22. <text class="stock-code">{{ stock.code }}</text>
  23. </view>
  24. <!-- 中间:涨跌趋势图 -->
  25. <view class="stock-chart">
  26. <canvas
  27. :canvas-id="canvasId"
  28. :id="canvasId"
  29. class="trend-canvas"
  30. ></canvas>
  31. </view>
  32. <!-- 右侧:涨跌幅和价格 -->
  33. <view class="stock-right">
  34. <view
  35. v-if="hasValidChange(stock.changePercent)"
  36. :class="['change-percent', getChangeClass(stock.changePercent)]"
  37. >
  38. {{ formatChangePercent(stock.changePercent) }}
  39. </view>
  40. <text class="stock-price">{{ formatPrice(stock.currentPrice) }}</text>
  41. </view>
  42. <!-- 删除按钮(在内容右侧) -->
  43. <view v-if="showDelete" class="delete-action" @click.stop="handleDelete">
  44. <view class="delete-icon-wrapper">
  45. <text class="delete-icon">−</text>
  46. </view>
  47. </view>
  48. </view>
  49. </movable-view>
  50. </movable-area>
  51. </view>
  52. </template>
  53. <script setup>
  54. import { onMounted, onUnmounted, nextTick, getCurrentInstance, ref, watch } from 'vue'
  55. const props = defineProps({
  56. stock: {
  57. type: Object,
  58. required: true
  59. },
  60. showDelete: {
  61. type: Boolean,
  62. default: false
  63. }
  64. })
  65. const emit = defineEmits(['delete'])
  66. // 保存组件实例引用
  67. let componentInstance = null
  68. // 滑动删除相关
  69. const deleteWidth = 60 // 删除按钮宽度(px)
  70. const moveX = ref(0)
  71. let currentX = 0
  72. let autoResetTimer = null // 自动还原计时器
  73. const AUTO_RESET_DELAY = 2000 // 自动还原延迟时间(ms)
  74. // 启动自动还原计时器
  75. const startAutoResetTimer = () => {
  76. clearAutoResetTimer()
  77. autoResetTimer = setTimeout(() => {
  78. moveX.value = 0
  79. currentX = 0
  80. }, AUTO_RESET_DELAY)
  81. }
  82. // 清除自动还原计时器
  83. const clearAutoResetTimer = () => {
  84. if (autoResetTimer) {
  85. clearTimeout(autoResetTimer)
  86. autoResetTimer = null
  87. }
  88. }
  89. // 生成稳定的 canvas ID(只在组件创建时生成一次)
  90. const canvasId = ref(`chart-${props.stock.code}-${Math.random().toString(36).slice(2, 11)}`)
  91. // 判断市场类型(沪市/深市/创业板)
  92. const getMarketTag = (code) => {
  93. if (code.startsWith('6')) return '沪'
  94. if (code.startsWith('0')) return '深'
  95. if (code.startsWith('3')) return '创'
  96. return '沪'
  97. }
  98. const getMarketClass = (code) => {
  99. if (code.startsWith('6')) return 'market-sh'
  100. if (code.startsWith('0')) return 'market-sz'
  101. if (code.startsWith('3')) return 'market-cy'
  102. return 'market-sh'
  103. }
  104. // 获取涨跌样式类
  105. const getChangeClass = (changePercent) => {
  106. if (!changePercent) return ''
  107. const str = String(changePercent).replace('%', '').replace('+', '')
  108. const value = parseFloat(str)
  109. if (value > 0) return 'change-up'
  110. if (value < 0) return 'change-down'
  111. return ''
  112. }
  113. // 格式化涨跌幅
  114. const formatChangePercent = (changePercent) => {
  115. if (!changePercent) return '--'
  116. return String(changePercent)
  117. }
  118. // 格式化价格
  119. const formatPrice = (price) => {
  120. if (!price) return '--'
  121. return parseFloat(price).toFixed(2)
  122. }
  123. // 判断是否有有效的涨跌幅(非0、非空)
  124. const hasValidChange = (changePercent) => {
  125. if (!changePercent) return false
  126. const str = String(changePercent).replace('%', '').replace('+', '')
  127. const value = parseFloat(str)
  128. return value !== 0 && !isNaN(value)
  129. }
  130. // 绘制趋势图
  131. const drawTrendChart = (instance) => {
  132. // 获取趋势数据
  133. let trendData = props.stock.trendData
  134. // 如果没有趋势数据,生成模拟数据
  135. if (!trendData || !Array.isArray(trendData) || trendData.length === 0) {
  136. trendData = generateMockTrendData()
  137. }
  138. // 使用 uni.createCanvasContext 创建画布上下文
  139. // 在 setup 中需要传入组件实例
  140. const ctx = uni.createCanvasContext(canvasId.value, instance)
  141. // 画布尺寸
  142. const width = 100 // 实际像素
  143. const height = 30 // 实际像素
  144. const padding = 2
  145. // 计算数据范围
  146. const maxValue = Math.max(...trendData)
  147. const minValue = Math.min(...trendData)
  148. const dataRange = maxValue - minValue
  149. // 设置最小范围为数据均值的3%,确保图表有明显起伏
  150. const avgValue = (maxValue + minValue) / 2
  151. const minRange = avgValue * 0.03 || 1
  152. const range = Math.max(dataRange, minRange)
  153. // 基准线位置(第一个数据点的值作为基准)
  154. const baseValue = trendData[0]
  155. const baseY = height - padding - ((baseValue - minValue) / range) * (height - padding * 2)
  156. // 判断涨跌颜色
  157. const changePercent = parseFloat(String(props.stock.changePercent || '0').replace('%', '').replace('+', ''))
  158. const isUp = changePercent >= 0
  159. const lineColor = isUp ? '#FF3B30' : '#34C759' // 红涨绿跌
  160. const fillColor = isUp ? 'rgba(255, 59, 48, 0.15)' : 'rgba(52, 199, 89, 0.15)'
  161. // 先绘制基准虚线
  162. ctx.beginPath()
  163. ctx.setStrokeStyle('#e0e0e0')
  164. ctx.setLineWidth(0.5)
  165. ctx.setLineDash([2, 2], 0)
  166. ctx.moveTo(padding, baseY)
  167. ctx.lineTo(width - padding, baseY)
  168. ctx.stroke()
  169. ctx.setLineDash([], 0) // 重置虚线
  170. // 绘制填充区域(从基准线到折线)
  171. ctx.beginPath()
  172. ctx.moveTo(padding, baseY)
  173. trendData.forEach((value, index) => {
  174. const x = padding + (index / (trendData.length - 1)) * (width - padding * 2)
  175. const y = height - padding - ((value - minValue) / range) * (height - padding * 2)
  176. ctx.lineTo(x, y)
  177. })
  178. ctx.lineTo(width - padding, baseY)
  179. ctx.closePath()
  180. ctx.setFillStyle(fillColor)
  181. ctx.fill()
  182. // 绘制折线
  183. ctx.beginPath()
  184. trendData.forEach((value, index) => {
  185. const x = padding + (index / (trendData.length - 1)) * (width - padding * 2)
  186. const y = height - padding - ((value - minValue) / range) * (height - padding * 2)
  187. if (index === 0) {
  188. ctx.moveTo(x, y)
  189. } else {
  190. ctx.lineTo(x, y)
  191. }
  192. })
  193. ctx.setStrokeStyle(lineColor)
  194. ctx.setLineWidth(1.5)
  195. ctx.stroke()
  196. // 绘制到画布
  197. ctx.draw()
  198. }
  199. // 生成模拟趋势数据
  200. const generateMockTrendData = () => {
  201. const changePercent = parseFloat(String(props.stock.changePercent || '0').replace('%', '').replace('+', ''))
  202. const points = 15 // 数据点数
  203. const data = []
  204. // 基于涨跌幅生成趋势数据
  205. let baseValue = 100
  206. const trend = changePercent / 100 // 总体趋势
  207. for (let i = 0; i < points; i++) {
  208. // 增大随机波动幅度,让图表更直观
  209. const randomChange = (Math.random() - 0.5) * 6
  210. const trendChange = (i / points) * trend * 100
  211. baseValue = baseValue + randomChange + trendChange / points
  212. data.push(baseValue)
  213. }
  214. return data
  215. }
  216. // 滑动变化
  217. const handleMoveChange = (e) => {
  218. currentX = e.detail.x
  219. // 如果滑动到了删除按钮位置,启动计时器
  220. if (props.showDelete && currentX <= -deleteWidth / 2) {
  221. startAutoResetTimer()
  222. }
  223. }
  224. // 滑动结束
  225. const handleMoveEnd = () => {
  226. if (!props.showDelete) return
  227. // 滑动超过三分之一就显示删除按钮
  228. if (currentX < -deleteWidth / 3) {
  229. moveX.value = -deleteWidth
  230. // 启动自动还原计时器
  231. startAutoResetTimer()
  232. } else {
  233. moveX.value = 0
  234. currentX = 0
  235. // 滑回初始位置,清除计时器
  236. clearAutoResetTimer()
  237. }
  238. }
  239. // 处理删除
  240. const handleDelete = () => {
  241. clearAutoResetTimer() // 点击删除时清除计时器
  242. moveX.value = 0
  243. emit('delete')
  244. }
  245. // 组件挂载后绘制图表
  246. onMounted(() => {
  247. componentInstance = getCurrentInstance()
  248. nextTick(() => {
  249. // 延迟绘制,确保 canvas 已渲染
  250. setTimeout(() => {
  251. drawTrendChart(componentInstance)
  252. }, 300)
  253. })
  254. })
  255. // 监听股票数据变化,重新绘制趋势图
  256. watch(() => props.stock.trendData, (newData) => {
  257. if (newData && componentInstance) {
  258. nextTick(() => {
  259. drawTrendChart(componentInstance)
  260. })
  261. }
  262. }, { deep: true })
  263. // 监听涨跌幅变化,更新颜色
  264. watch(() => props.stock.changePercent, () => {
  265. if (componentInstance) {
  266. nextTick(() => {
  267. drawTrendChart(componentInstance)
  268. })
  269. }
  270. })
  271. // 监听滑动位置变化,确保自动还原计时器正确工作
  272. watch(moveX, (newVal) => {
  273. if (newVal < 0 && props.showDelete) {
  274. // 滑动到删除位置,确保计时器在运行
  275. if (!autoResetTimer) {
  276. startAutoResetTimer()
  277. }
  278. } else if (newVal === 0) {
  279. // 已还原,清除计时器
  280. clearAutoResetTimer()
  281. }
  282. })
  283. // 组件销毁时清理计时器
  284. onUnmounted(() => {
  285. clearAutoResetTimer()
  286. })
  287. </script>
  288. <style scoped>
  289. .stock-item-wrapper {
  290. position: relative;
  291. height: 120rpx;
  292. overflow: hidden;
  293. background: #ffffff;
  294. }
  295. .movable-area {
  296. width: 100%;
  297. height: 100%;
  298. }
  299. .movable-view {
  300. width: calc(100% + 120rpx);
  301. height: 100%;
  302. }
  303. .stock-list-item {
  304. display: flex;
  305. align-items: center;
  306. padding: 24rpx 0;
  307. height: 100%;
  308. box-sizing: border-box;
  309. background: #ffffff;
  310. border-bottom: 1rpx solid #f1f2f6;
  311. }
  312. .stock-item-wrapper:last-child .stock-list-item {
  313. border-bottom: none;
  314. }
  315. /* 滑动删除按钮 */
  316. .delete-action {
  317. flex-shrink: 0;
  318. width: 120rpx;
  319. height: 100%;
  320. background: #ffffff;
  321. display: flex;
  322. align-items: center;
  323. justify-content: center;
  324. }
  325. .delete-icon-wrapper {
  326. width: 64rpx;
  327. height: 64rpx;
  328. background: #FF3B30;
  329. border-radius: 50%;
  330. display: flex;
  331. align-items: center;
  332. justify-content: center;
  333. box-shadow: 0 4rpx 12rpx rgba(255, 59, 48, 0.3);
  334. }
  335. .delete-icon {
  336. font-size: 32rpx;
  337. color: #ffffff;
  338. font-weight: bold;
  339. }
  340. /* 左侧股票信息 */
  341. .stock-left {
  342. flex-shrink: 0;
  343. width: 160rpx;
  344. display: flex;
  345. flex-direction: column;
  346. }
  347. .stock-name-row {
  348. display: flex;
  349. align-items: center;
  350. margin-bottom: 8rpx;
  351. }
  352. .stock-name {
  353. font-size: 26rpx;
  354. font-weight: 600;
  355. color: #222222;
  356. margin-right: 8rpx;
  357. }
  358. .stock-tag {
  359. font-size: 18rpx;
  360. padding: 2rpx 6rpx;
  361. border-radius: 4rpx;
  362. color: #ffffff;
  363. font-weight: 500;
  364. }
  365. .market-sh {
  366. background: #FF3B30;
  367. }
  368. .market-sz {
  369. background: #34C759;
  370. }
  371. .market-cy {
  372. background: #FF9500;
  373. }
  374. .stock-code {
  375. font-size: 22rpx;
  376. color: #9ca2b5;
  377. }
  378. /* 中间趋势图 */
  379. .stock-chart {
  380. flex: 1;
  381. height: 60rpx;
  382. display: flex;
  383. align-items: center;
  384. justify-content: center;
  385. margin: 0 16rpx;
  386. }
  387. .trend-canvas {
  388. width: 200rpx;
  389. height: 60rpx;
  390. }
  391. /* 右侧涨跌幅和价格 */
  392. .stock-right {
  393. flex-shrink: 0;
  394. width: 120rpx;
  395. display: flex;
  396. flex-direction: column;
  397. align-items: flex-end;
  398. }
  399. .change-percent {
  400. font-size: 26rpx;
  401. font-weight: 700;
  402. padding: 4rpx 12rpx;
  403. border-radius: 6rpx;
  404. margin-bottom: 8rpx;
  405. }
  406. .change-up {
  407. background: #FF3B30;
  408. color: #ffffff;
  409. }
  410. .change-down {
  411. background: #34C759;
  412. color: #ffffff;
  413. }
  414. .stock-price {
  415. font-size: 22rpx;
  416. color: #666a7f;
  417. }
  418. </style>