| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- <template>
- <view class="stock-item-wrapper">
- <!-- 可滑动的内容区域 -->
- <movable-area class="movable-area">
- <movable-view
- class="movable-view"
- direction="horizontal"
- :x="moveX"
- :damping="40"
- :friction="5"
- :out-of-bounds="false"
- @change="handleMoveChange"
- @touchend="handleMoveEnd"
- >
- <view class="stock-list-item">
- <!-- 左侧:股票信息 -->
- <view class="stock-left">
- <view class="stock-name-row">
- <text class="stock-name">{{ stock.name }}</text>
- <text :class="['stock-tag', getMarketClass(stock.code)]">{{ getMarketTag(stock.code) }}</text>
- </view>
- <text class="stock-code">{{ stock.code }}</text>
- </view>
- <!-- 中间:涨跌趋势图 -->
- <view class="stock-chart">
- <canvas
- :canvas-id="canvasId"
- :id="canvasId"
- class="trend-canvas"
- ></canvas>
- </view>
- <!-- 右侧:涨跌幅和价格 -->
- <view class="stock-right">
- <view
- v-if="hasValidChange(stock.changePercent)"
- :class="['change-percent', getChangeClass(stock.changePercent)]"
- >
- {{ formatChangePercent(stock.changePercent) }}
- </view>
- <text class="stock-price">{{ formatPrice(stock.currentPrice) }}</text>
- </view>
-
- <!-- 删除按钮(在内容右侧) -->
- <view v-if="showDelete" class="delete-action" @click.stop="handleDelete">
- <view class="delete-icon-wrapper">
- <text class="delete-icon">−</text>
- </view>
- </view>
- </view>
- </movable-view>
- </movable-area>
- </view>
- </template>
- <script setup>
- import { onMounted, onUnmounted, nextTick, getCurrentInstance, ref, watch } from 'vue'
- const props = defineProps({
- stock: {
- type: Object,
- required: true
- },
- showDelete: {
- type: Boolean,
- default: false
- }
- })
- const emit = defineEmits(['delete'])
- // 保存组件实例引用
- let componentInstance = null
- // 滑动删除相关
- const deleteWidth = 60 // 删除按钮宽度(px)
- const moveX = ref(0)
- let currentX = 0
- let autoResetTimer = null // 自动还原计时器
- let checkTimer = null // 检查计时器(备用)
- const AUTO_RESET_DELAY = 2500 // 自动还原延迟时间(ms)
- let isSlideOpen = false // 标记滑动是否打开
- // 强制还原到初始位置
- const forceReset = () => {
- console.log('[滑动删除] 强制还原')
- moveX.value = 0
- currentX = 0
- isSlideOpen = false
- clearAutoResetTimer()
- }
- // 启动自动还原计时器
- const startAutoResetTimer = () => {
- // 清除之前的计时器
- clearAutoResetTimer()
-
- isSlideOpen = true
- console.log('[滑动删除] 启动自动还原计时器')
-
- // 主计时器
- autoResetTimer = setTimeout(() => {
- console.log('[滑动删除] 主计时器触发')
- forceReset()
- }, AUTO_RESET_DELAY)
-
- // 备用检查计时器,每500ms检查一次,确保一定会还原
- checkTimer = setInterval(() => {
- if (isSlideOpen && moveX.value < 0) {
- // 如果主计时器失效,备用计时器在3秒后强制还原
- console.log('[滑动删除] 备用检查中...')
- } else if (isSlideOpen && moveX.value === 0) {
- // 已经还原了,清理状态
- isSlideOpen = false
- clearAutoResetTimer()
- }
- }, 500)
-
- // 额外的保险:3.5秒后无论如何都还原
- setTimeout(() => {
- if (isSlideOpen) {
- console.log('[滑动删除] 保险计时器触发')
- forceReset()
- }
- }, AUTO_RESET_DELAY + 1000)
- }
- // 清除自动还原计时器
- const clearAutoResetTimer = () => {
- if (autoResetTimer) {
- clearTimeout(autoResetTimer)
- autoResetTimer = null
- }
- if (checkTimer) {
- clearInterval(checkTimer)
- checkTimer = null
- }
- }
- // 生成稳定的 canvas ID(只在组件创建时生成一次)
- const canvasId = ref(`chart-${props.stock.code}-${Math.random().toString(36).slice(2, 11)}`)
- // 判断市场类型(沪市/深市/创业板)
- const getMarketTag = (code) => {
- if (code.startsWith('6')) return '沪'
- if (code.startsWith('0')) return '深'
- if (code.startsWith('3')) return '创'
- return '沪'
- }
- const getMarketClass = (code) => {
- if (code.startsWith('6')) return 'market-sh'
- if (code.startsWith('0')) return 'market-sz'
- if (code.startsWith('3')) return 'market-cy'
- return 'market-sh'
- }
- // 获取涨跌样式类
- const getChangeClass = (changePercent) => {
- if (!changePercent) return ''
- const str = String(changePercent).replace('%', '').replace('+', '')
- const value = parseFloat(str)
- if (value > 0) return 'change-up'
- if (value < 0) return 'change-down'
- return ''
- }
- // 格式化涨跌幅
- const formatChangePercent = (changePercent) => {
- if (!changePercent) return '--'
- return String(changePercent)
- }
- // 格式化价格
- const formatPrice = (price) => {
- if (!price) return '--'
- return parseFloat(price).toFixed(2)
- }
- // 判断是否有有效的涨跌幅(非0、非空)
- const hasValidChange = (changePercent) => {
- if (!changePercent) return false
- const str = String(changePercent).replace('%', '').replace('+', '')
- const value = parseFloat(str)
- return value !== 0 && !isNaN(value)
- }
- // 绘制趋势图
- const drawTrendChart = (instance) => {
- // 获取趋势数据
- let trendData = props.stock.trendData
-
- // 如果没有趋势数据,生成模拟数据
- if (!trendData || !Array.isArray(trendData) || trendData.length === 0) {
- trendData = generateMockTrendData()
- }
-
- // 使用 uni.createCanvasContext 创建画布上下文
- // 在 setup 中需要传入组件实例
- const ctx = uni.createCanvasContext(canvasId.value, instance)
-
- // 画布尺寸
- const width = 100 // 实际像素
- const height = 30 // 实际像素
- const padding = 2
-
- // 计算数据范围
- const maxValue = Math.max(...trendData)
- const minValue = Math.min(...trendData)
- const dataRange = maxValue - minValue
- // 设置最小范围为数据均值的3%,确保图表有明显起伏
- const avgValue = (maxValue + minValue) / 2
- const minRange = avgValue * 0.03 || 1
- const range = Math.max(dataRange, minRange)
-
- // 基准线位置(第一个数据点的值作为基准)
- const baseValue = trendData[0]
- const baseY = height - padding - ((baseValue - minValue) / range) * (height - padding * 2)
-
- // 判断涨跌颜色
- const changePercent = parseFloat(String(props.stock.changePercent || '0').replace('%', '').replace('+', ''))
- const isUp = changePercent >= 0
- const lineColor = isUp ? '#FF3B30' : '#34C759' // 红涨绿跌
- const fillColor = isUp ? 'rgba(255, 59, 48, 0.15)' : 'rgba(52, 199, 89, 0.15)'
-
- // 先绘制基准虚线
- ctx.beginPath()
- ctx.setStrokeStyle('#e0e0e0')
- ctx.setLineWidth(0.5)
- ctx.setLineDash([2, 2], 0)
- ctx.moveTo(padding, baseY)
- ctx.lineTo(width - padding, baseY)
- ctx.stroke()
- ctx.setLineDash([], 0) // 重置虚线
-
- // 绘制填充区域(从基准线到折线)
- ctx.beginPath()
- ctx.moveTo(padding, baseY)
-
- trendData.forEach((value, index) => {
- const x = padding + (index / (trendData.length - 1)) * (width - padding * 2)
- const y = height - padding - ((value - minValue) / range) * (height - padding * 2)
- ctx.lineTo(x, y)
- })
-
- ctx.lineTo(width - padding, baseY)
- ctx.closePath()
- ctx.setFillStyle(fillColor)
- ctx.fill()
-
- // 绘制折线
- ctx.beginPath()
-
- trendData.forEach((value, index) => {
- const x = padding + (index / (trendData.length - 1)) * (width - padding * 2)
- const y = height - padding - ((value - minValue) / range) * (height - padding * 2)
-
- if (index === 0) {
- ctx.moveTo(x, y)
- } else {
- ctx.lineTo(x, y)
- }
- })
-
- ctx.setStrokeStyle(lineColor)
- ctx.setLineWidth(1.5)
- ctx.stroke()
-
- // 绘制到画布
- ctx.draw()
- }
- // 生成模拟趋势数据
- const generateMockTrendData = () => {
- const changePercent = parseFloat(String(props.stock.changePercent || '0').replace('%', '').replace('+', ''))
- const points = 15 // 数据点数
- const data = []
-
- // 基于涨跌幅生成趋势数据
- let baseValue = 100
- const trend = changePercent / 100 // 总体趋势
-
- for (let i = 0; i < points; i++) {
- // 增大随机波动幅度,让图表更直观
- const randomChange = (Math.random() - 0.5) * 6
- const trendChange = (i / points) * trend * 100
- baseValue = baseValue + randomChange + trendChange / points
- data.push(baseValue)
- }
-
- return data
- }
- // 滑动变化
- const handleMoveChange = (e) => {
- currentX = e.detail.x
- }
- // 滑动结束
- const handleMoveEnd = () => {
- if (!props.showDelete) return
-
- // 滑动超过三分之一就显示删除按钮
- if (currentX < -deleteWidth / 3) {
- moveX.value = -deleteWidth
- // 启动自动还原计时器
- startAutoResetTimer()
- } else {
- // 滑回初始位置
- forceReset()
- }
- }
- // 处理删除
- const handleDelete = () => {
- forceReset() // 点击删除时还原位置
- emit('delete')
- }
- // 组件挂载后绘制图表
- onMounted(() => {
- componentInstance = getCurrentInstance()
- nextTick(() => {
- // 延迟绘制,确保 canvas 已渲染
- setTimeout(() => {
- drawTrendChart(componentInstance)
- }, 300)
- })
- })
- // 监听股票数据变化,重新绘制趋势图
- watch(() => props.stock.trendData, (newData) => {
- if (newData && componentInstance) {
- nextTick(() => {
- drawTrendChart(componentInstance)
- })
- }
- }, { deep: true })
- // 监听涨跌幅变化,更新颜色
- watch(() => props.stock.changePercent, () => {
- if (componentInstance) {
- nextTick(() => {
- drawTrendChart(componentInstance)
- })
- }
- })
- // 监听滑动位置变化
- watch(moveX, (newVal) => {
- if (newVal === 0 && isSlideOpen) {
- // 已还原,清理状态
- isSlideOpen = false
- clearAutoResetTimer()
- }
- })
- // 组件销毁时清理计时器
- onUnmounted(() => {
- clearAutoResetTimer()
- })
- </script>
- <style scoped>
- .stock-item-wrapper {
- position: relative;
- height: 120rpx;
- overflow: hidden;
- background: #ffffff;
- }
- .movable-area {
- width: 100%;
- height: 100%;
- }
- .movable-view {
- width: calc(100% + 120rpx);
- height: 100%;
- }
- .stock-list-item {
- display: flex;
- align-items: center;
- padding: 24rpx 0;
- height: 100%;
- box-sizing: border-box;
- background: #ffffff;
- border-bottom: 1rpx solid #f1f2f6;
- }
- .stock-item-wrapper:last-child .stock-list-item {
- border-bottom: none;
- }
- /* 滑动删除按钮 */
- .delete-action {
- flex-shrink: 0;
- width: 120rpx;
- height: 100%;
- background: #ffffff;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .delete-icon-wrapper {
- width: 64rpx;
- height: 64rpx;
- background: #FF3B30;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 4rpx 12rpx rgba(255, 59, 48, 0.3);
- }
- .delete-icon {
- font-size: 32rpx;
- color: #ffffff;
- font-weight: bold;
- }
- /* 左侧股票信息 */
- .stock-left {
- flex-shrink: 0;
- width: 160rpx;
- display: flex;
- flex-direction: column;
- }
- .stock-name-row {
- display: flex;
- align-items: center;
- margin-bottom: 8rpx;
- }
- .stock-name {
- font-size: 26rpx;
- font-weight: 600;
- color: #222222;
- margin-right: 8rpx;
- }
- .stock-tag {
- font-size: 18rpx;
- padding: 2rpx 6rpx;
- border-radius: 4rpx;
- color: #ffffff;
- font-weight: 500;
- }
- .market-sh {
- background: #FF3B30;
- }
- .market-sz {
- background: #34C759;
- }
- .market-cy {
- background: #FF9500;
- }
- .stock-code {
- font-size: 22rpx;
- color: #9ca2b5;
- }
- /* 中间趋势图 */
- .stock-chart {
- flex: 1;
- height: 60rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- margin: 0 16rpx;
- }
- .trend-canvas {
- width: 200rpx;
- height: 60rpx;
- }
- /* 右侧涨跌幅和价格 */
- .stock-right {
- flex-shrink: 0;
- width: 120rpx;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- }
- .change-percent {
- font-size: 26rpx;
- font-weight: 700;
- padding: 4rpx 12rpx;
- border-radius: 6rpx;
- margin-bottom: 8rpx;
- }
- .change-up {
- background: #FF3B30;
- color: #ffffff;
- }
- .change-down {
- background: #34C759;
- color: #ffffff;
- }
- .stock-price {
- font-size: 22rpx;
- color: #666a7f;
- }
- </style>
|