rank.vue 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378
  1. <template>
  2. <view class="page-rank">
  3. <scroll-view class="scroll-view" scroll-y>
  4. <view class="content-wrapper" :class="{ 'blur-content': !isLoggedIn }">
  5. <!-- 上证指数卡片 -->
  6. <view class="index-card">
  7. <view class="index-left">
  8. <view class="index-price-row">
  9. <text :class="['index-price', getIndexChangeClass(indexData.changePercent)]">
  10. {{ formatIndexPrice(indexData.currentPrice) }}
  11. </text>
  12. <text :class="['index-change', getIndexChangeClass(indexData.changePercent)]">
  13. {{ indexData.priceChange || '--' }}
  14. </text>
  15. </view>
  16. <view class="index-name-row">
  17. <text class="index-name">{{ indexData.stockName || '上证指数' }}</text>
  18. <text :class="['index-percent', getIndexChangeClass(indexData.changePercent)]">
  19. {{ indexData.changePercent || '--' }}
  20. </text>
  21. </view>
  22. </view>
  23. </view>
  24. <!-- 分段控制器 -->
  25. <view class="segment-control">
  26. <view class="segment-slider" :class="{ 'slider-right': viewMode === 'table' }"></view>
  27. <view
  28. class="segment-item"
  29. :class="{ 'segment-active': viewMode === 'list' }"
  30. @click="setViewMode('list')"
  31. >
  32. <text class="segment-text">热力图</text>
  33. </view>
  34. <view
  35. class="segment-item"
  36. :class="{ 'segment-active': viewMode === 'table' }"
  37. @click="setViewMode('table')"
  38. >
  39. <text class="segment-text">详情</text>
  40. </view>
  41. </view>
  42. <!-- 热力图视图 -->
  43. <view v-show="viewMode === 'list'" class="stock-list-card" :class="{ 'hidden-list': myStocks.length === 0 }">
  44. <view class="list-header">
  45. <view class="list-dot"></view>
  46. <text class="list-title">我的自选</text>
  47. <text class="list-count">{{ myStocks.length }}只</text>
  48. </view>
  49. <view class="stock-list">
  50. <view class="stock-item-wrapper" v-for="(stock, index) in myStocks" :key="stock.code">
  51. <movable-area class="movable-area">
  52. <movable-view
  53. class="movable-view"
  54. direction="horizontal"
  55. :x="slideStates[stock.code]?.x || 0"
  56. :damping="40"
  57. :friction="5"
  58. :out-of-bounds="false"
  59. @change="(e) => handleSlideChange(e, stock.code)"
  60. @touchend="() => handleSlideEnd(stock.code)"
  61. >
  62. <view class="stock-item">
  63. <view class="stock-main">
  64. <view class="stock-info">
  65. <text class="stock-name">{{ stock.name }}</text>
  66. <view class="stock-code-row">
  67. <text :class="['stock-tag', getMarketClass(stock.code)]">{{ getMarketTag(stock.code) }}</text>
  68. <text class="stock-code">{{ stock.code }}</text>
  69. </view>
  70. </view>
  71. <!-- 趋势图 - 使用 canvas 2d 模式解决滚动分离问题 -->
  72. <view class="stock-chart">
  73. <canvas
  74. type="2d"
  75. :id="'chart-' + stock.code"
  76. class="trend-canvas"
  77. ></canvas>
  78. </view>
  79. <view class="stock-quote">
  80. <text class="stock-price">{{ stock.currentPrice || '-' }}</text>
  81. <text :class="['stock-change', getProfitClass(stock.changePercent)]">{{ stock.changePercent || '-' }}</text>
  82. </view>
  83. </view>
  84. <!-- 滑动删除按钮 -->
  85. <view class="slide-delete-btn" @click.stop="removeStock(index)">
  86. <view class="delete-icon-circle">
  87. <text class="delete-icon-text">−</text>
  88. </view>
  89. </view>
  90. </view>
  91. </movable-view>
  92. </movable-area>
  93. </view>
  94. </view>
  95. </view>
  96. <!-- 详情表格视图 -->
  97. <view v-show="viewMode === 'table'" class="stock-table-card" :class="{ 'hidden-list': myStocks.length === 0 }">
  98. <view class="list-header">
  99. <view class="list-dot list-dot-blue"></view>
  100. <text class="list-title">详情数据</text>
  101. </view>
  102. <!-- 表头 -->
  103. <view class="table-header">
  104. <text class="th-name">股票</text>
  105. <text class="th-date">自选日</text>
  106. <text class="th-price">自选价</text>
  107. <text class="th-profit">收益</text>
  108. </view>
  109. <!-- 表格内容 -->
  110. <view class="table-list">
  111. <view
  112. v-for="(stock, index) in myStocks"
  113. :key="stock.code"
  114. class="table-row"
  115. >
  116. <view class="td-name">
  117. <text class="stock-name">{{ stock.name }}</text>
  118. <view class="stock-code-row">
  119. <text :class="['stock-tag', getMarketClass(stock.code)]">{{ getMarketTag(stock.code) }}</text>
  120. <text class="stock-code">{{ stock.code }}</text>
  121. </view>
  122. </view>
  123. <text class="td-date">{{ stock.addDate || '--' }}</text>
  124. <text class="td-price">{{ formatPrice(stock.addPrice) }}</text>
  125. <view class="td-profit-wrap">
  126. <text :class="['td-profit', getProfitClass(stock.profitPercent)]">
  127. {{ stock.profitPercent || '--' }}
  128. </text>
  129. </view>
  130. </view>
  131. </view>
  132. </view>
  133. <!-- 空状态 -->
  134. <view v-if="myStocks.length === 0" class="empty-content">
  135. <view class="empty-icon">📊</view>
  136. <text class="empty-text">暂无收藏股票</text>
  137. <text class="empty-desc">在强势池中点击"+"按钮添加股票</text>
  138. </view>
  139. <!-- 底部安全区域 -->
  140. <view class="bottom-safe-area"></view>
  141. </view>
  142. </scroll-view>
  143. <!-- 未登录遮罩层 -->
  144. <view v-if="!isLoggedIn" class="login-mask">
  145. <view class="login-prompt">
  146. <view class="lock-icon">🔒</view>
  147. <text class="prompt-title">登录后查看我的股票</text>
  148. <text class="prompt-desc">使用微信授权快速登录</text>
  149. <button class="login-button-native" @click="goToLogin">
  150. <text>登录</text>
  151. </button>
  152. </view>
  153. </view>
  154. </view>
  155. </template>
  156. <script setup>
  157. import { ref, nextTick, getCurrentInstance, reactive } from 'vue'
  158. import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
  159. import { isLoggedIn as checkLoginStatus } from '../../utils/auth.js'
  160. import { getStockQuotes, getIndexQuote, getUserStocks, deleteUserStock } from '../../utils/api.js'
  161. // 保存组件实例
  162. let componentInstance = null
  163. const isLoggedIn = ref(false)
  164. const myStocks = ref([])
  165. const viewMode = ref('list') // 'list' 或 'table'
  166. const isLoading = ref(false) // 加载状态
  167. const lastLoadTime = ref(0) // 上次加载时间
  168. const CACHE_DURATION = 5000 // 缓存有效期5秒
  169. const isPageVisible = ref(false) // 页面是否可见
  170. // 获取随机刷新间隔 (2000-3000ms)
  171. const getRandomInterval = () => 2000 + Math.random() * 1000
  172. // 滑动删除相关
  173. const SLIDE_WIDTH = 100 // 滑动距离(三分之一宽度约100rpx转换)
  174. const slideStates = reactive({}) // 每个股票的滑动状态
  175. let slideTimers = {} // 自动还原计时器
  176. const AUTO_RESET_DELAY = 2000 // 2秒后自动还原
  177. // 初始化滑动状态
  178. const initSlideState = (code) => {
  179. if (!slideStates[code]) {
  180. slideStates[code] = { x: 0, currentX: 0 }
  181. }
  182. }
  183. // 处理滑动变化
  184. const handleSlideChange = (e, code) => {
  185. initSlideState(code)
  186. slideStates[code].currentX = e.detail.x
  187. }
  188. // 处理滑动结束
  189. const handleSlideEnd = (code) => {
  190. initSlideState(code)
  191. const state = slideStates[code]
  192. // 滑动超过20px就显示删除按钮(吸附到三分之一处)
  193. if (state.currentX < -20) {
  194. state.x = -SLIDE_WIDTH
  195. // 启动自动还原计时器(2秒)
  196. startAutoResetTimer(code)
  197. } else {
  198. // 滑回初始位置
  199. resetSlide(code)
  200. }
  201. }
  202. // 重置滑动位置
  203. const resetSlide = (code) => {
  204. if (slideStates[code]) {
  205. slideStates[code].x = 0
  206. slideStates[code].currentX = 0
  207. }
  208. clearSlideTimer(code)
  209. }
  210. // 启动自动还原计时器
  211. const startAutoResetTimer = (code) => {
  212. clearSlideTimer(code)
  213. slideTimers[code] = setTimeout(() => {
  214. resetSlide(code)
  215. }, AUTO_RESET_DELAY) // 2秒后自动还原
  216. }
  217. // 清除计时器
  218. const clearSlideTimer = (code) => {
  219. if (slideTimers[code]) {
  220. clearTimeout(slideTimers[code])
  221. delete slideTimers[code]
  222. }
  223. }
  224. // 清除所有计时器
  225. const clearAllSlideTimers = () => {
  226. Object.keys(slideTimers).forEach(code => {
  227. clearSlideTimer(code)
  228. })
  229. }
  230. // 设置视图模式
  231. const setViewMode = (mode) => {
  232. viewMode.value = mode
  233. // 切换到热力图视图时,先清空再重绘趋势图
  234. if (mode === 'list' && myStocks.value.length > 0) {
  235. nextTick(() => {
  236. clearAllCanvases()
  237. drawAllTrendCharts()
  238. })
  239. }
  240. }
  241. const indexData = ref({
  242. stockCode: '000001',
  243. stockName: '上证指数',
  244. currentPrice: null,
  245. priceChange: null,
  246. changePercent: null
  247. })
  248. let priceRefreshTimer = null // 价格刷新定时器 (2-3秒)
  249. let trendRefreshTimer = null // 趋势图刷新定时器 (10秒)
  250. // 获取上证指数数据
  251. const fetchIndexData = async () => {
  252. try {
  253. const res = await getIndexQuote('000001')
  254. if (res.code === 200 && res.data) {
  255. indexData.value = { ...indexData.value, ...res.data }
  256. }
  257. } catch (e) {
  258. console.error('[上证指数] 获取失败:', e.message)
  259. }
  260. }
  261. // 格式化指数价格
  262. const formatIndexPrice = (price) => {
  263. if (!price) return '--'
  264. return parseFloat(price).toFixed(2)
  265. }
  266. // 格式化价格
  267. const formatPrice = (price) => {
  268. if (!price) return '--'
  269. return parseFloat(price).toFixed(2)
  270. }
  271. // 获取指数涨跌样式类
  272. const getIndexChangeClass = (changePercent) => {
  273. if (!changePercent) return ''
  274. const str = String(changePercent).replace('%', '').replace('+', '')
  275. const value = parseFloat(str)
  276. if (value > 0) return 'index-up'
  277. if (value < 0) return 'index-down'
  278. return ''
  279. }
  280. // 获取收益样式类
  281. const getProfitClass = (profitPercent) => {
  282. if (!profitPercent) return ''
  283. const str = String(profitPercent).replace('%', '').replace('+', '')
  284. const value = parseFloat(str)
  285. if (value > 0) return 'profit-up'
  286. if (value < 0) return 'profit-down'
  287. return ''
  288. }
  289. // 获取市场标签
  290. const getMarketTag = (code) => {
  291. if (code.startsWith('6')) return '沪'
  292. if (code.startsWith('0')) return '深'
  293. if (code.startsWith('3')) return '创'
  294. return '沪'
  295. }
  296. const getMarketClass = (code) => {
  297. if (code.startsWith('6')) return 'market-sh'
  298. if (code.startsWith('0')) return 'market-sz'
  299. if (code.startsWith('3')) return 'market-cy'
  300. return 'market-sh'
  301. }
  302. // 获取设备像素比(使用新API替代废弃的getSystemInfoSync)
  303. const getDevicePixelRatio = () => {
  304. try {
  305. const windowInfo = wx.getWindowInfo()
  306. return windowInfo.pixelRatio || 2
  307. } catch (e) {
  308. return 2 // 默认值
  309. }
  310. }
  311. // 清空单个 canvas
  312. const clearCanvas = (canvasId) => {
  313. const query = uni.createSelectorQuery()
  314. query.select('#' + canvasId)
  315. .fields({ node: true, size: true })
  316. .exec((res) => {
  317. if (!res || !res[0] || !res[0].node) return
  318. const canvas = res[0].node
  319. const ctx = canvas.getContext('2d')
  320. const dpr = getDevicePixelRatio()
  321. const width = res[0].width
  322. const height = res[0].height
  323. canvas.width = width * dpr
  324. canvas.height = height * dpr
  325. ctx.setTransform(1, 0, 0, 1, 0, 0)
  326. ctx.clearRect(0, 0, width * dpr, height * dpr)
  327. })
  328. }
  329. // 清空所有趋势图 canvas
  330. const clearAllCanvases = () => {
  331. myStocks.value.forEach(stock => {
  332. clearCanvas('chart-' + stock.code)
  333. })
  334. }
  335. // 绘制单个股票的趋势图 (使用 Canvas 2D API)
  336. const drawTrendChart = (stock) => {
  337. const canvasId = 'chart-' + stock.code
  338. let trendData = stock.trendData
  339. // 如果没有趋势数据,生成模拟数据
  340. if (!trendData || !Array.isArray(trendData) || trendData.length === 0) {
  341. trendData = generateMockTrendData(stock.changePercent)
  342. }
  343. // 使用 Canvas 2D 模式
  344. const query = uni.createSelectorQuery()
  345. query.select('#' + canvasId)
  346. .fields({ node: true, size: true })
  347. .exec((res) => {
  348. if (!res || !res[0] || !res[0].node) {
  349. console.warn('[趋势图] 获取canvas节点失败:', canvasId)
  350. return
  351. }
  352. const canvas = res[0].node
  353. const ctx = canvas.getContext('2d')
  354. const dpr = getDevicePixelRatio()
  355. // 设置 canvas 实际尺寸
  356. const width = res[0].width
  357. const height = res[0].height
  358. // 重置 canvas 尺寸(强制清除之前的绘制状态,解决页面切换后显示异常)
  359. canvas.width = width * dpr
  360. canvas.height = height * dpr
  361. // 重置变换矩阵后再设置缩放
  362. ctx.setTransform(1, 0, 0, 1, 0, 0)
  363. ctx.scale(dpr, dpr)
  364. const padding = 1
  365. const maxValue = Math.max(...trendData)
  366. const minValue = Math.min(...trendData)
  367. const dataRange = maxValue - minValue
  368. const avgValue = (maxValue + minValue) / 2
  369. const minRange = avgValue * 0.03 || 1
  370. const range = Math.max(dataRange, minRange)
  371. const baseValue = trendData[0]
  372. const baseY = height - padding - ((baseValue - minValue) / range) * (height - padding * 2)
  373. const changePercent = parseFloat(String(stock.changePercent || '0').replace('%', '').replace('+', ''))
  374. const isUp = changePercent >= 0
  375. const lineColor = isUp ? '#ef4444' : '#22c55e'
  376. const fillColor = isUp ? 'rgba(239, 68, 68, 0.15)' : 'rgba(34, 197, 94, 0.15)'
  377. // 清除画布
  378. ctx.clearRect(0, 0, width, height)
  379. // 绘制基准虚线
  380. ctx.beginPath()
  381. ctx.strokeStyle = '#e5e7eb'
  382. ctx.lineWidth = 0.5
  383. ctx.setLineDash([2, 2])
  384. ctx.moveTo(padding, baseY)
  385. ctx.lineTo(width - padding, baseY)
  386. ctx.stroke()
  387. ctx.setLineDash([])
  388. // 绘制填充区域
  389. ctx.beginPath()
  390. ctx.moveTo(padding, baseY)
  391. trendData.forEach((value, index) => {
  392. const x = padding + (index / (trendData.length - 1)) * (width - padding * 2)
  393. const y = height - padding - ((value - minValue) / range) * (height - padding * 2)
  394. ctx.lineTo(x, y)
  395. })
  396. ctx.lineTo(width - padding, baseY)
  397. ctx.closePath()
  398. ctx.fillStyle = fillColor
  399. ctx.fill()
  400. // 绘制折线
  401. ctx.beginPath()
  402. trendData.forEach((value, index) => {
  403. const x = padding + (index / (trendData.length - 1)) * (width - padding * 2)
  404. const y = height - padding - ((value - minValue) / range) * (height - padding * 2)
  405. if (index === 0) {
  406. ctx.moveTo(x, y)
  407. } else {
  408. ctx.lineTo(x, y)
  409. }
  410. })
  411. ctx.strokeStyle = lineColor
  412. ctx.lineWidth = 1.5
  413. ctx.stroke()
  414. })
  415. }
  416. // 生成模拟趋势数据
  417. const generateMockTrendData = (changePercent) => {
  418. const change = parseFloat(String(changePercent || '0').replace('%', '').replace('+', ''))
  419. const points = 15
  420. const data = []
  421. let baseValue = 100
  422. const trend = change / 100
  423. for (let i = 0; i < points; i++) {
  424. const randomChange = (Math.random() - 0.5) * 6
  425. const trendChange = (i / points) * trend * 100
  426. baseValue = baseValue + randomChange + trendChange / points
  427. data.push(baseValue)
  428. }
  429. return data
  430. }
  431. // 绘制所有股票的趋势图
  432. const drawAllTrendCharts = () => {
  433. if (myStocks.value.length === 0) return
  434. // 确保页面可见时才绘制
  435. if (!isPageVisible.value) return
  436. nextTick(() => {
  437. // 增加延迟确保 canvas 节点完全渲染(解决页面切换后 canvas 上下文丢失问题)
  438. setTimeout(() => {
  439. if (!isPageVisible.value) return
  440. myStocks.value.forEach((stock, index) => {
  441. setTimeout(() => {
  442. if (isPageVisible.value) {
  443. drawTrendChart(stock)
  444. }
  445. }, index * 30)
  446. })
  447. }, 350)
  448. })
  449. }
  450. // 加载我的股票列表(从服务器数据库查询)
  451. const loadMyStocks = async (forceRefresh = false) => {
  452. console.log('[我的股票] loadMyStocks 开始执行, isLoggedIn=', isLoggedIn.value, 'forceRefresh=', forceRefresh)
  453. if (!isLoggedIn.value) {
  454. myStocks.value = []
  455. stopAutoRefresh()
  456. return
  457. }
  458. // 如果正在加载中,跳过
  459. if (isLoading.value) {
  460. console.log('[我的股票] 正在加载中,跳过')
  461. return
  462. }
  463. // 如果有缓存数据且未过期,只刷新行情不重新加载列表
  464. const now = Date.now()
  465. if (!forceRefresh && myStocks.value.length > 0 && (now - lastLoadTime.value) < CACHE_DURATION) {
  466. console.log('[我的股票] 使用缓存数据,只刷新行情')
  467. // 只刷新行情数据
  468. await fetchIndexData()
  469. await refreshAllQuotes()
  470. // 重新绘制趋势图(解决页面切换回来后canvas显示异常的问题)
  471. drawAllTrendCharts()
  472. startAutoRefresh()
  473. return
  474. }
  475. try {
  476. isLoading.value = true
  477. // 只有首次加载或强制刷新时显示loading
  478. let showedLoading = false
  479. if (myStocks.value.length === 0 || forceRefresh) {
  480. uni.showLoading({ title: '加载中...' })
  481. showedLoading = true
  482. }
  483. // 从服务器获取用户自选股票
  484. console.log('[我的股票] 调用 getUserStocks 接口')
  485. const res = await getUserStocks()
  486. console.log('[我的股票] 服务器返回:', JSON.stringify(res))
  487. if (showedLoading) {
  488. uni.hideLoading()
  489. }
  490. if (res.code === 200 && res.data) {
  491. // 转换数据格式
  492. myStocks.value = res.data.map(item => ({
  493. code: item.stockCode,
  494. name: item.stockName,
  495. addPrice: item.addPrice,
  496. addDate: item.addDate,
  497. currentPrice: item.currentPrice,
  498. profitPercent: item.profitPercent,
  499. priceChange: item.priceChange,
  500. changePercent: item.changePercent,
  501. trendData: item.trendData
  502. }))
  503. lastLoadTime.value = Date.now()
  504. console.log('[我的股票] 加载完成, 股票数量:', myStocks.value.length)
  505. } else {
  506. myStocks.value = []
  507. console.log('[我的股票] 返回数据为空')
  508. }
  509. // 获取上证指数
  510. await fetchIndexData()
  511. // 如果有股票数据,刷新行情
  512. if (myStocks.value.length > 0) {
  513. await refreshAllQuotes()
  514. // 绘制趋势图
  515. drawAllTrendCharts()
  516. }
  517. // 登录后启动定时刷新
  518. startAutoRefresh()
  519. } catch (e) {
  520. // 安全地隐藏loading
  521. try { uni.hideLoading() } catch (err) {}
  522. console.error('[我的股票] 加载失败:', e)
  523. // 加载失败时不清空已有数据
  524. if (myStocks.value.length === 0) {
  525. myStocks.value = []
  526. }
  527. startAutoRefresh()
  528. } finally {
  529. isLoading.value = false
  530. }
  531. }
  532. // 批量刷新所有股票行情(只更新价格,不更新趋势图)
  533. const refreshPriceOnly = async () => {
  534. if (myStocks.value.length === 0) return
  535. try {
  536. const codes = myStocks.value.map(stock => stock.code).join(',')
  537. const quoteRes = await getStockQuotes(codes)
  538. if (quoteRes.code === 200 && quoteRes.data && quoteRes.data.length > 0) {
  539. quoteRes.data.forEach(quoteData => {
  540. const index = myStocks.value.findIndex(stock => stock.code === quoteData.stockCode)
  541. if (index !== -1) {
  542. const stock = myStocks.value[index]
  543. stock.priceChange = quoteData.priceChange
  544. stock.changePercent = quoteData.changePercent
  545. stock.currentPrice = quoteData.currentPrice
  546. stock.name = quoteData.stockName || stock.name
  547. // 不更新 trendData
  548. // 计算自选收益(当前价格相对于加入价格的涨跌幅)
  549. if (stock.addPrice && quoteData.currentPrice) {
  550. const addPrice = parseFloat(stock.addPrice)
  551. const currentPrice = parseFloat(quoteData.currentPrice)
  552. if (addPrice > 0) {
  553. const profit = ((currentPrice - addPrice) / addPrice * 100).toFixed(2)
  554. stock.profitPercent = profit >= 0 ? `+${profit}%` : `${profit}%`
  555. }
  556. }
  557. }
  558. })
  559. }
  560. } catch (e) {
  561. console.error('[我的股票] 价格刷新异常:', e.message)
  562. }
  563. }
  564. // 批量刷新所有股票行情(包含趋势图)
  565. const refreshAllQuotes = async () => {
  566. if (myStocks.value.length === 0) return
  567. try {
  568. const codes = myStocks.value.map(stock => stock.code).join(',')
  569. const quoteRes = await getStockQuotes(codes)
  570. if (quoteRes.code === 200 && quoteRes.data && quoteRes.data.length > 0) {
  571. quoteRes.data.forEach(quoteData => {
  572. const index = myStocks.value.findIndex(stock => stock.code === quoteData.stockCode)
  573. if (index !== -1) {
  574. const stock = myStocks.value[index]
  575. stock.priceChange = quoteData.priceChange
  576. stock.changePercent = quoteData.changePercent
  577. stock.currentPrice = quoteData.currentPrice
  578. stock.name = quoteData.stockName || stock.name
  579. stock.trendData = quoteData.trendData || null
  580. // 计算自选收益(当前价格相对于加入价格的涨跌幅)
  581. if (stock.addPrice && quoteData.currentPrice) {
  582. const addPrice = parseFloat(stock.addPrice)
  583. const currentPrice = parseFloat(quoteData.currentPrice)
  584. if (addPrice > 0) {
  585. const profit = ((currentPrice - addPrice) / addPrice * 100).toFixed(2)
  586. stock.profitPercent = profit >= 0 ? `+${profit}%` : `${profit}%`
  587. }
  588. }
  589. }
  590. })
  591. }
  592. } catch (e) {
  593. console.error('[我的股票] 刷新异常:', e.message)
  594. }
  595. }
  596. // 启动定时刷新(价格2-3秒,趋势图10秒)
  597. const startAutoRefresh = () => {
  598. if (!isLoggedIn.value || !isPageVisible.value) return
  599. stopAutoRefresh()
  600. // 价格刷新定时器 (2-3秒随机间隔)
  601. const schedulePriceRefresh = () => {
  602. if (!isPageVisible.value) {
  603. stopAutoRefresh()
  604. return
  605. }
  606. priceRefreshTimer = setTimeout(async () => {
  607. if (!isPageVisible.value) {
  608. stopAutoRefresh()
  609. return
  610. }
  611. await fetchIndexData()
  612. if (myStocks.value.length > 0) {
  613. await refreshPriceOnly()
  614. }
  615. schedulePriceRefresh()
  616. }, getRandomInterval())
  617. }
  618. // 趋势图刷新定时器 (10秒固定间隔)
  619. const scheduleTrendRefresh = () => {
  620. if (!isPageVisible.value) {
  621. stopAutoRefresh()
  622. return
  623. }
  624. trendRefreshTimer = setTimeout(async () => {
  625. if (!isPageVisible.value) {
  626. stopAutoRefresh()
  627. return
  628. }
  629. if (myStocks.value.length > 0) {
  630. await refreshAllQuotes()
  631. drawAllTrendCharts()
  632. }
  633. scheduleTrendRefresh()
  634. }, 10000)
  635. }
  636. schedulePriceRefresh()
  637. scheduleTrendRefresh()
  638. }
  639. // 停止定时刷新
  640. const stopAutoRefresh = () => {
  641. if (priceRefreshTimer) {
  642. clearTimeout(priceRefreshTimer)
  643. priceRefreshTimer = null
  644. }
  645. if (trendRefreshTimer) {
  646. clearTimeout(trendRefreshTimer)
  647. trendRefreshTimer = null
  648. }
  649. }
  650. // 跳转到登录页
  651. const goToLogin = () => {
  652. uni.navigateTo({ url: '/pages/login/login' })
  653. }
  654. // 删除股票
  655. const removeStock = async (idx) => {
  656. const stock = myStocks.value[idx]
  657. // 先重置滑动状态
  658. resetSlide(stock.code)
  659. uni.showModal({
  660. title: '确认删除',
  661. content: `确定要删除 ${stock.name} 吗?`,
  662. confirmText: '删除',
  663. cancelText: '取消',
  664. success: async (res) => {
  665. if (res.confirm) {
  666. try {
  667. // 调用服务器删除接口
  668. await deleteUserStock(stock.code)
  669. // 清理滑动状态
  670. delete slideStates[stock.code]
  671. clearSlideTimer(stock.code)
  672. // 从本地列表删除
  673. myStocks.value.splice(idx, 1)
  674. uni.showToast({ title: '删除成功', icon: 'success' })
  675. if (myStocks.value.length === 0) {
  676. stopAutoRefresh()
  677. }
  678. } catch (e) {
  679. console.error('删除失败:', e)
  680. uni.showToast({ title: '删除失败', icon: 'none' })
  681. }
  682. }
  683. }
  684. })
  685. }
  686. onLoad(() => {
  687. console.log('[我的股票] onLoad 触发')
  688. componentInstance = getCurrentInstance()
  689. isLoggedIn.value = checkLoginStatus()
  690. isPageVisible.value = true
  691. loadMyStocks(true) // 首次加载强制刷新
  692. })
  693. onShow(() => {
  694. console.log('[我的股票] onShow 触发')
  695. isPageVisible.value = true
  696. // 立即清空所有 canvas,避免显示异常的长条
  697. if (myStocks.value.length > 0) {
  698. clearAllCanvases()
  699. }
  700. const wasLoggedIn = isLoggedIn.value
  701. isLoggedIn.value = checkLoginStatus()
  702. // 如果登录状态变化了,强制刷新
  703. if (wasLoggedIn !== isLoggedIn.value) {
  704. loadMyStocks(true)
  705. } else {
  706. // 否则使用缓存策略
  707. loadMyStocks(false)
  708. }
  709. uni.setNavigationBarTitle({ title: '量化交易大师' })
  710. })
  711. onHide(() => {
  712. console.log('[我的股票] onHide 触发')
  713. isPageVisible.value = false
  714. stopAutoRefresh()
  715. })
  716. onUnload(() => {
  717. console.log('[我的股票] onUnload 触发')
  718. isPageVisible.value = false
  719. stopAutoRefresh()
  720. clearAllSlideTimers()
  721. })
  722. </script>
  723. <style>
  724. .page-rank {
  725. display: flex;
  726. flex-direction: column;
  727. background: #f5f6fb;
  728. height: 100vh;
  729. }
  730. .scroll-view {
  731. flex: 1;
  732. height: 0;
  733. }
  734. .content-wrapper {
  735. padding: 32rpx;
  736. min-height: 100%;
  737. }
  738. /* 上证指数卡片 */
  739. .index-card {
  740. display: flex;
  741. align-items: center;
  742. justify-content: space-between;
  743. background: #ffffff;
  744. border-radius: 24rpx;
  745. padding: 32rpx;
  746. margin-bottom: 24rpx;
  747. box-shadow: 0 8rpx 24rpx rgba(37, 52, 94, 0.08);
  748. }
  749. .index-left {
  750. display: flex;
  751. flex-direction: column;
  752. }
  753. .index-price-row {
  754. display: flex;
  755. align-items: baseline;
  756. margin-bottom: 8rpx;
  757. }
  758. .index-price {
  759. font-size: 48rpx;
  760. font-weight: 700;
  761. margin-right: 16rpx;
  762. }
  763. .index-change {
  764. font-size: 28rpx;
  765. font-weight: 600;
  766. }
  767. .index-name-row {
  768. display: flex;
  769. align-items: center;
  770. }
  771. .index-name {
  772. font-size: 26rpx;
  773. color: #666666;
  774. margin-right: 12rpx;
  775. }
  776. .index-percent {
  777. font-size: 26rpx;
  778. font-weight: 600;
  779. }
  780. .index-up {
  781. color: #FF3B30;
  782. }
  783. .index-down {
  784. color: #34C759;
  785. }
  786. /* 分段控制器 */
  787. .segment-control {
  788. display: flex;
  789. align-items: center;
  790. background: #F5F7FA;
  791. border-radius: 32rpx;
  792. padding: 6rpx;
  793. margin-bottom: 24rpx;
  794. position: relative;
  795. height: 64rpx;
  796. box-sizing: border-box;
  797. }
  798. .segment-slider {
  799. position: absolute;
  800. left: 6rpx;
  801. top: 6rpx;
  802. width: calc(50% - 6rpx);
  803. height: calc(100% - 12rpx);
  804. background: #ffffff;
  805. border-radius: 26rpx;
  806. box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.05);
  807. transition: transform 0.3s ease;
  808. z-index: 0;
  809. }
  810. .segment-slider.slider-right {
  811. transform: translateX(100%);
  812. }
  813. .segment-item {
  814. flex: 1;
  815. display: flex;
  816. align-items: center;
  817. justify-content: center;
  818. height: 100%;
  819. z-index: 1;
  820. transition: color 0.3s ease;
  821. }
  822. .segment-icon {
  823. font-size: 24rpx;
  824. margin-right: 8rpx;
  825. color: #999999;
  826. transition: color 0.3s ease;
  827. }
  828. .segment-text {
  829. font-size: 26rpx;
  830. color: #999999;
  831. font-weight: 500;
  832. transition: color 0.3s ease;
  833. }
  834. .segment-active .segment-icon,
  835. .segment-active .segment-text {
  836. color: #E53935;
  837. }
  838. /* 隐藏空列表 */
  839. .hidden-list {
  840. display: none !important;
  841. }
  842. /* 股票列表卡片 */
  843. .stock-list-card,
  844. .stock-table-card {
  845. background: #ffffff;
  846. border-radius: 32rpx;
  847. padding: 32rpx;
  848. box-shadow: 0 16rpx 48rpx rgba(37, 52, 94, 0.1);
  849. }
  850. .list-header {
  851. display: flex;
  852. align-items: center;
  853. margin-bottom: 28rpx;
  854. }
  855. .list-dot {
  856. width: 12rpx;
  857. height: 12rpx;
  858. border-radius: 50%;
  859. background: #3abf81;
  860. margin-right: 12rpx;
  861. }
  862. .list-dot-blue {
  863. background: #5B5AEA;
  864. }
  865. .list-title {
  866. font-size: 28rpx;
  867. font-weight: 600;
  868. color: #1a1a2e;
  869. letter-spacing: 1rpx;
  870. }
  871. .list-count {
  872. font-size: 24rpx;
  873. color: #9ca3af;
  874. margin-left: 12rpx;
  875. }
  876. /* 股票列表 */
  877. .stock-list {
  878. display: flex;
  879. flex-direction: column;
  880. gap: 16rpx;
  881. }
  882. /* 滑动删除容器 */
  883. .stock-item-wrapper {
  884. position: relative;
  885. height: 160rpx;
  886. overflow: hidden;
  887. border-radius: 20rpx;
  888. background: #fafbfc;
  889. }
  890. .movable-area {
  891. width: 100%;
  892. height: 100%;
  893. }
  894. .movable-view {
  895. width: calc(100% + 160rpx);
  896. height: 100%;
  897. }
  898. .stock-item {
  899. display: flex;
  900. align-items: center;
  901. padding: 20rpx 16rpx;
  902. background: #fafbfc;
  903. border-radius: 20rpx;
  904. height: 100%;
  905. box-sizing: border-box;
  906. }
  907. /* 滑动删除按钮 */
  908. .slide-delete-btn {
  909. flex-shrink: 0;
  910. width: 160rpx;
  911. height: 100%;
  912. display: flex;
  913. align-items: center;
  914. justify-content: center;
  915. background: #fafbfc;
  916. }
  917. .delete-icon-circle {
  918. width: 72rpx;
  919. height: 72rpx;
  920. background: #ef4444;
  921. border-radius: 50%;
  922. display: flex;
  923. align-items: center;
  924. justify-content: center;
  925. box-shadow: 0 6rpx 16rpx rgba(239, 68, 68, 0.4);
  926. }
  927. .delete-icon-text {
  928. font-size: 40rpx;
  929. color: #ffffff;
  930. font-weight: bold;
  931. line-height: 1;
  932. }
  933. .stock-main {
  934. display: flex;
  935. align-items: center;
  936. flex: 1;
  937. width: 100%;
  938. }
  939. .stock-info {
  940. display: flex;
  941. flex-direction: column;
  942. min-width: 130rpx;
  943. flex-shrink: 0;
  944. }
  945. .stock-name {
  946. font-size: 30rpx;
  947. font-weight: 700;
  948. color: #1a1a2e;
  949. margin-bottom: 8rpx;
  950. letter-spacing: 0.5rpx;
  951. text-rendering: optimizeLegibility;
  952. -webkit-font-smoothing: antialiased;
  953. }
  954. .stock-code-row {
  955. display: flex;
  956. align-items: center;
  957. }
  958. .stock-tag {
  959. font-size: 18rpx;
  960. padding: 3rpx 8rpx;
  961. border-radius: 6rpx;
  962. color: #ffffff;
  963. font-weight: 600;
  964. margin-right: 6rpx;
  965. flex-shrink: 0;
  966. }
  967. .market-sh {
  968. background: #ef4444;
  969. }
  970. .market-sz {
  971. background: #22c55e;
  972. }
  973. .market-cy {
  974. background: #f59e0b;
  975. }
  976. .stock-code {
  977. font-size: 22rpx;
  978. color: #6b7280;
  979. font-weight: 500;
  980. text-rendering: optimizeLegibility;
  981. }
  982. .stock-quote {
  983. display: flex;
  984. flex-direction: column;
  985. align-items: flex-end;
  986. flex-shrink: 0;
  987. min-width: 140rpx;
  988. }
  989. /* 趋势图 */
  990. .stock-chart {
  991. flex: 1;
  992. height: 56rpx;
  993. display: flex;
  994. align-items: center;
  995. justify-content: center;
  996. margin: 0 16rpx;
  997. }
  998. .trend-canvas {
  999. width: 100%;
  1000. height: 56rpx;
  1001. }
  1002. .stock-price {
  1003. font-size: 32rpx;
  1004. font-weight: 700;
  1005. color: #1a1a2e;
  1006. margin-bottom: 6rpx;
  1007. font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', sans-serif;
  1008. text-rendering: optimizeLegibility;
  1009. -webkit-font-smoothing: antialiased;
  1010. }
  1011. .stock-change {
  1012. font-size: 22rpx;
  1013. font-weight: 600;
  1014. padding: 4rpx 10rpx;
  1015. border-radius: 8rpx;
  1016. text-rendering: optimizeLegibility;
  1017. }
  1018. /* 表格视图 */
  1019. .table-header {
  1020. display: flex;
  1021. align-items: center;
  1022. padding: 20rpx 16rpx;
  1023. background: #f8f9fc;
  1024. border-radius: 12rpx;
  1025. margin-bottom: 16rpx;
  1026. }
  1027. .table-header text {
  1028. font-size: 24rpx;
  1029. color: #9ca3af;
  1030. font-weight: 600;
  1031. }
  1032. .th-name {
  1033. flex: 1;
  1034. }
  1035. .th-date {
  1036. width: 140rpx;
  1037. text-align: center;
  1038. }
  1039. .th-price {
  1040. width: 120rpx;
  1041. text-align: center;
  1042. }
  1043. .th-profit {
  1044. width: 120rpx;
  1045. text-align: right;
  1046. }
  1047. .table-list {
  1048. display: flex;
  1049. flex-direction: column;
  1050. gap: 12rpx;
  1051. }
  1052. .table-row {
  1053. display: flex;
  1054. align-items: center;
  1055. padding: 24rpx 16rpx;
  1056. background: #fafbfc;
  1057. border-radius: 16rpx;
  1058. }
  1059. .td-name {
  1060. flex: 1;
  1061. display: flex;
  1062. flex-direction: column;
  1063. }
  1064. .td-name .stock-name {
  1065. font-size: 28rpx;
  1066. font-weight: 700;
  1067. color: #1a1a2e;
  1068. margin-bottom: 4rpx;
  1069. }
  1070. .td-date {
  1071. width: 140rpx;
  1072. text-align: center;
  1073. font-size: 24rpx;
  1074. color: #6b7280;
  1075. }
  1076. .td-price {
  1077. width: 120rpx;
  1078. text-align: center;
  1079. font-size: 26rpx;
  1080. font-weight: 600;
  1081. color: #1a1a2e;
  1082. }
  1083. .td-profit-wrap {
  1084. width: 120rpx;
  1085. display: flex;
  1086. justify-content: flex-end;
  1087. }
  1088. .td-profit {
  1089. font-size: 24rpx;
  1090. font-weight: 600;
  1091. padding: 6rpx 12rpx;
  1092. border-radius: 8rpx;
  1093. }
  1094. .profit-up {
  1095. color: #ef4444;
  1096. background: rgba(239, 68, 68, 0.1);
  1097. }
  1098. .profit-down {
  1099. color: #22c55e;
  1100. background: rgba(34, 197, 94, 0.1);
  1101. }
  1102. /* 空状态 */
  1103. .empty-content {
  1104. display: flex;
  1105. flex-direction: column;
  1106. align-items: center;
  1107. justify-content: center;
  1108. padding: 200rpx 60rpx;
  1109. }
  1110. .empty-icon {
  1111. font-size: 120rpx;
  1112. margin-bottom: 40rpx;
  1113. }
  1114. .empty-text {
  1115. font-size: 32rpx;
  1116. font-weight: 600;
  1117. color: #333333;
  1118. margin-bottom: 16rpx;
  1119. }
  1120. .empty-desc {
  1121. font-size: 26rpx;
  1122. color: #999999;
  1123. text-align: center;
  1124. line-height: 1.6;
  1125. }
  1126. .bottom-safe-area {
  1127. height: 80rpx;
  1128. }
  1129. /* 模糊效果 */
  1130. .blur-content {
  1131. filter: blur(8rpx);
  1132. pointer-events: none;
  1133. }
  1134. /* 登录遮罩层 */
  1135. .login-mask {
  1136. position: fixed;
  1137. top: 0;
  1138. left: 0;
  1139. right: 0;
  1140. bottom: 0;
  1141. background: rgba(0, 0, 0, 0.4);
  1142. display: flex;
  1143. align-items: center;
  1144. justify-content: center;
  1145. z-index: 999;
  1146. }
  1147. .login-prompt {
  1148. width: 560rpx;
  1149. max-width: 560rpx;
  1150. background: #ffffff;
  1151. border-radius: 24rpx;
  1152. padding: 50rpx 40rpx;
  1153. margin: 0 auto;
  1154. text-align: center;
  1155. box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
  1156. box-sizing: border-box;
  1157. }
  1158. .lock-icon {
  1159. font-size: 72rpx;
  1160. margin-bottom: 20rpx;
  1161. }
  1162. .prompt-title {
  1163. display: block;
  1164. font-size: 32rpx;
  1165. font-weight: 600;
  1166. color: #222222;
  1167. margin-bottom: 12rpx;
  1168. }
  1169. .prompt-desc {
  1170. display: block;
  1171. font-size: 24rpx;
  1172. color: #999999;
  1173. line-height: 1.5;
  1174. margin-bottom: 32rpx;
  1175. }
  1176. .login-button-native {
  1177. width: 100%;
  1178. height: 80rpx;
  1179. background: linear-gradient(135deg, #4CAF50, #66BB6A);
  1180. color: #ffffff;
  1181. border-radius: 40rpx;
  1182. font-size: 30rpx;
  1183. font-weight: 600;
  1184. box-shadow: 0 8rpx 24rpx rgba(76, 175, 80, 0.4);
  1185. display: flex;
  1186. align-items: center;
  1187. justify-content: center;
  1188. border: none;
  1189. padding: 0;
  1190. line-height: 80rpx;
  1191. }
  1192. .login-button-native::after {
  1193. border: none;
  1194. }
  1195. </style>