StockListItem.vue 12 KB

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