You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2258 lines
62 KiB

4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
4 weeks ago
  1. <!-- @format -->
  2. <template>
  3. <!-- 容器 -->
  4. <view class="container">
  5. <!-- 标题 -->
  6. <view class="title-container">
  7. <view class="title">
  8. <image class="back-homepage-btn" src="/static/marketSituation-image/back.png" mode="返回按钮" @click="backToHomepage()"> </image>
  9. <view class="mid-title">
  10. <view class="arrow-left"> </view>
  11. <view class="stock-id">
  12. <view class="stock-name"> {{ stockInformation.stockName }} </view>
  13. <view class="stock-code"> {{ stockInformation.stockCode }} </view>
  14. </view>
  15. <view class="arrow-right"> </view>
  16. </view>
  17. <image class="search" src="/static/marketSituation-image/search.png" mode="搜索" @click="search()"> </image>
  18. <view class="more" @click="getMore()">···</view>
  19. </view>
  20. </view>
  21. <view class="body">
  22. <!-- 股票信息栏 -->
  23. <view class="stock-information" @click="openStockDetail()">
  24. <view v-if="isStockDetail" class="stock-detail-container" @click.stop @click="closeStockDetail()">
  25. <view class="stock-detail" @click.stop>
  26. <view class="first-column">
  27. <view class="first-column-data" v-for="item in firstColumData" :key="item">
  28. <view class="stock-detail-title">
  29. {{ item.title }}
  30. </view>
  31. <view class="stock-detail-value">
  32. {{ item.value }}
  33. </view>
  34. </view>
  35. </view>
  36. <view class="second-column">
  37. <view class="second-column-data" v-for="item in secondColumnData" :key="item">
  38. <view class="stock-detail-title">
  39. {{ item.title }}
  40. </view>
  41. <view class="stock-detail-value">
  42. {{ item.value }}
  43. </view>
  44. </view>
  45. </view>
  46. </view>
  47. </view>
  48. <view class="stock-current-data">
  49. <view class="stock-current-price" :class="confirmStockColor(stockInformation.currentPrice, stockInformation.lastDayStockClosePrice)">
  50. {{ Number(stockInformation.currentPrice).toFixed(2) }}
  51. </view>
  52. <view class="stock-current-other">
  53. <view class="stock-current-value" :class="confirmStockColor(stockInformation.currentValue)">
  54. {{ Number(stockInformation.currentValue).toFixed(2) }}
  55. </view>
  56. <view class="stock-current-ratio" :class="confirmStockColor(stockInformation.currentRatio)"> {{ Number(stockInformation.currentRatio).toFixed(2) }}% </view>
  57. </view>
  58. </view>
  59. <view class="stock-other-data">
  60. <view class="first-line">
  61. <view class="high-price">
  62. <view class="value" :class="confirmStockColor(stockInformation.highPrice, stockInformation.lastDayStockClosePrice)">
  63. {{ Number(stockInformation.highPrice).toFixed(2) }}
  64. </view>
  65. </view>
  66. <view class="volume">
  67. <view class="value">
  68. {{ utils.formatStockNumber(stockInformation.volume, 2) }}
  69. </view>
  70. </view>
  71. <view class="volume-ratio">
  72. 量比
  73. <view class="value">
  74. {{ Number(stockInformation.volumeRatio).toFixed(2) }}
  75. </view>
  76. </view>
  77. </view>
  78. <view class="second-line">
  79. <view class="low-price">
  80. <view class="value" :class="confirmStockColor(stockInformation.lowPrice, stockInformation.lastDayStockClosePrice)">
  81. {{ Number(stockInformation.lowPrice).toFixed(2) }}
  82. </view>
  83. </view>
  84. <view class="amount">
  85. <view class="value">
  86. {{ utils.formatStockNumber(stockInformation.amount, 2) }}
  87. </view>
  88. </view>
  89. <view class="market-earn">
  90. 市盈
  91. <view class="value">
  92. {{ Number(stockInformation.marketEarn).toFixed(2) }}
  93. </view>
  94. </view>
  95. </view>
  96. <view class="third-line">
  97. <view class="open-price">
  98. <view class="value" :class="confirmStockColor(stockInformation.openPrice, stockInformation.lastDayStockClosePrice)">
  99. {{ Number(stockInformation.openPrice).toFixed(2) }}
  100. </view>
  101. </view>
  102. <view class="turnover-ratio">
  103. <view class="value"> {{ Number(stockInformation.turnoverRatio).toFixed(2) }}% </view>
  104. </view>
  105. <view class="market-value">
  106. 市值
  107. <view class="value">
  108. {{ utils.formatStockNumber(stockInformation.marketValue, 2) }}
  109. </view>
  110. </view>
  111. </view>
  112. </view>
  113. </view>
  114. <!-- 股票图表 -->
  115. <view class="stock-chart">
  116. <view class="stock-kline-tab">
  117. <!-- 1:分时 2:日K 3:周K 4:月K -->
  118. <view class="tab-time" :class="{ 'tab-selected': klineTab == 1 }" @click="selectKlineTab(1)"> 分时 </view>
  119. <view class="tab-day" :class="{ 'tab-selected': klineTab == 2 }" @click="selectKlineTab(2)"> 日K </view>
  120. <view class="tab-week" :class="{ 'tab-selected': klineTab == 3 }" @click="selectKlineTab(3)"> 周K </view>
  121. <view class="tab-month" :class="{ 'tab-selected': klineTab == 4 }" @click="selectKlineTab(4)"> 月K </view>
  122. <view class="tab-more" :class="{ 'tab-selected': klineTab != 1 && klineTab != 2 && klineTab != 3 && klineTab != 4 }" @click="isMoreTabs ? closeMoreTabs() : openMoreTabs()">
  123. <view class="more-no-choose" v-if="klineTab == 1 || klineTab == 2 || klineTab == 3 || klineTab == 4"> 更多 </view>
  124. <view class="more-choose" v-else>
  125. {{ chooseTabName(klineTab) }}
  126. </view>
  127. <view :class="[isMoreTabs ? 'arrow-down' : 'arrow-up']"> </view>
  128. </view>
  129. <view class="tab-setting">
  130. <image class="tab-setting-img" src="/static/marketSituation-image/marketCondition-image/setting2.png" mode="设置"></image>
  131. </view>
  132. </view>
  133. <view v-if="isMoreTabs" class="moreTabsContainer">
  134. <view v-for="item in moreTabsData" :key="item" class="moreTabItem" :class="{ 'tab-selected': klineTab == item.value }" @click="selectMoreTab(item.value)">
  135. {{ item.title }}
  136. </view>
  137. </view>
  138. <view class="stock-kline">
  139. <view v-if="klineTab === 1 || klineTab === 2" class="time-chart-container" style="position: relative">
  140. <!-- 主图Canvas -->
  141. <canvas
  142. canvas-id="stockChart"
  143. class="stock-chart"
  144. :width="canvasWidth"
  145. :height="canvasHeight"
  146. :style="{
  147. width: canvasWidth + 'px',
  148. height: canvasHeight + 'px',
  149. pointerEvents: 'none',
  150. }"
  151. will-read-frequently="true"
  152. ></canvas>
  153. <!-- 动态数据Canvas层 -->
  154. <canvas
  155. canvas-id="dynamicCanvas"
  156. id="dynamicCanvas"
  157. :width="canvasWidth"
  158. :height="canvasHeight"
  159. :style="{
  160. width: canvasWidth + 'px',
  161. height: canvasHeight + 'px',
  162. pointerEvents: 'none',
  163. position: 'absolute',
  164. top: '0',
  165. left: '0',
  166. }"
  167. will-read-frequently="true"
  168. ></canvas>
  169. <!-- 十字准心Canvas层 -->
  170. <canvas
  171. canvas-id="crosshairCanvas"
  172. id="crosshairCanvas"
  173. :width="canvasWidth"
  174. :height="canvasHeight"
  175. :style="{
  176. width: canvasWidth + 'px',
  177. height: canvasHeight + 'px',
  178. position: 'absolute',
  179. top: '0',
  180. left: '0',
  181. }"
  182. will-read-frequently="true"
  183. @touchstart="touchStart"
  184. @touchmove="touchMove"
  185. @touchend="touchEnd"
  186. ></canvas>
  187. </view>
  188. <!-- K线图区域 -->
  189. <view class="test" v-else-if="klineTab === 3">
  190. <button @click="startTcp()">接收消息</button>
  191. <button @click="sendStopTimeData()">停止消息</button>
  192. <button @click="sendTcpMessage('real_time')">实时行情推送</button>
  193. <button @click="sendTcpMessage('init_real_time')">初始化获取行情历史数据</button>
  194. <button @click="sendTcpMessage('stop_real_time')">停止实时推送</button>
  195. <view class="tcpMsg" v-for="item in tcpMessages" :key="item">
  196. {{ item }}
  197. </view>
  198. </view>
  199. <view v-else class="kline-chart-container">
  200. <text>K线图开发中...</text>
  201. </view>
  202. </view>
  203. </view>
  204. </view>
  205. <view class="bottomTool">
  206. <view class="index">
  207. <image class="icon" src="/static/marketSituation-image/marketCondition-image/index.png" mode="指标仓库图标"> </image>
  208. 指标仓库
  209. </view>
  210. <view class="function">
  211. <image class="icon" src="/static/marketSituation-image/marketCondition-image/function.png" mode="功能图标"> </image>
  212. 功能
  213. </view>
  214. <view class="favorites">
  215. <image class="icon" src="/static/marketSituation-image/marketCondition-image/favorites.png" mode="加自选图标"></image>
  216. 加自选
  217. </view>
  218. </view>
  219. </view>
  220. </template>
  221. <script setup>
  222. import { ref, reactive, computed, onMounted, watch, nextTick, onUnmounted, getCurrentInstance } from "vue";
  223. import { onLoad } from "@dcloudio/uni-app";
  224. const instance = getCurrentInstance();
  225. import { prevClosePrice, timeData as testTimeData, klineData as testKlineData } from "@/common/stockTimeInformation.js";
  226. import { throttle } from "@/common/util.js";
  227. import { HCharts } from "@/common/canvasMethod.js";
  228. import tcpConnection, { TCPConnection, TCP_CONFIG } from "@/api/tcpConnection.js";
  229. // TCP相关响应式变量
  230. const tcpConnected = ref(false);
  231. const tcpMessages = ref([]);
  232. const tcpStockData = ref({
  233. count: 0,
  234. data: {},
  235. stock_count: 0,
  236. timestamp: "",
  237. type: "",
  238. });
  239. const currentStockInfo = ref({
  240. stock_name: "未知股票",
  241. current_price: "0.00",
  242. change: "0.00%",
  243. change_value: 0,
  244. change_percent: 0,
  245. });
  246. const connectionListener = ref(null);
  247. const messageListener = ref(null);
  248. // 股票信息栏变量
  249. const stockInformation = ref({
  250. stockName: "----", //股票名称
  251. stockCode: "------", //股票代码
  252. lastDayStockClosePrice: 0.0, //前一日收盘价
  253. currentPrice: 0.0, //当前股价
  254. currentValue: 0.0, //涨跌额度
  255. currentRatio: 0.0, //涨跌幅度
  256. highPrice: 0.0, //最高价
  257. lowPrice: 0.0, //最低价
  258. openPrice: 0.0, //开盘价
  259. closePrice: 0.0, //收盘价
  260. volume: 0.0, //成交量
  261. volumeRatio: 0.0, //成交量比
  262. amount: 0.0, //成交额
  263. marketEarn: 0.0, //市盈
  264. turnoverRatio: 0.0, //换手率
  265. marketValue: 0.0, //市值
  266. });
  267. // 是否展开股票信息细节栏的判断变量
  268. const isStockDetail = ref(false);
  269. // 股票信息细节内容变量
  270. const firstColumData = [
  271. {
  272. title: "振幅",
  273. value: 0,
  274. },
  275. {
  276. title: "现手",
  277. value: 0,
  278. },
  279. {
  280. title: "内盘",
  281. value: 0,
  282. },
  283. {
  284. title: "外盘",
  285. value: 0,
  286. },
  287. {
  288. title: "流通股",
  289. value: 0,
  290. },
  291. {
  292. title: "每股净资产",
  293. value: 0,
  294. },
  295. {
  296. title: "净资产收益率",
  297. value: 0,
  298. },
  299. {
  300. title: "总股本",
  301. value: 0,
  302. },
  303. {
  304. title: "总资本",
  305. value: 0,
  306. },
  307. {
  308. title: "总市值",
  309. value: 0,
  310. },
  311. {
  312. title: "一个月最高",
  313. value: 0,
  314. },
  315. {
  316. title: "一个月最低",
  317. value: 0,
  318. },
  319. ];
  320. const secondColumnData = [
  321. {
  322. title: "均价",
  323. value: 0,
  324. },
  325. {
  326. title: "昨收",
  327. value: 0,
  328. },
  329. {
  330. title: "委比",
  331. value: 0,
  332. },
  333. {
  334. title: "委买",
  335. value: 0,
  336. },
  337. {
  338. title: "委卖",
  339. value: 0,
  340. },
  341. {
  342. title: "市盈利(静)",
  343. value: 0,
  344. },
  345. {
  346. title: "市盈利(动)",
  347. value: 0,
  348. },
  349. {
  350. title: "市净率",
  351. value: 0,
  352. },
  353. {
  354. title: "涨停价",
  355. value: 0,
  356. },
  357. {
  358. title: "跌停价",
  359. value: 0,
  360. },
  361. {
  362. title: "一年最高",
  363. value: 0,
  364. },
  365. {
  366. title: "一年最低",
  367. value: 0,
  368. },
  369. ];
  370. // 是否展开更多Tab的判断变量
  371. const isMoreTabs = ref(false);
  372. const moreTabsData = ref([
  373. {
  374. title: "1分",
  375. value: 5,
  376. },
  377. {
  378. title: "5分",
  379. value: 6,
  380. },
  381. {
  382. title: "15分",
  383. value: 7,
  384. },
  385. {
  386. title: "30分",
  387. value: 8,
  388. },
  389. {
  390. title: "60分",
  391. value: 9,
  392. },
  393. {
  394. title: "季K",
  395. value: 10,
  396. },
  397. {
  398. title: "年K",
  399. value: 11,
  400. },
  401. ]);
  402. // 股票当前选中的K线类型Tab
  403. // 1:分时 2:日K 3:周K 4:月K
  404. const klineTab = ref(1);
  405. const startTcp = () => {
  406. try {
  407. removeTcpListeners();
  408. disconnectTcp();
  409. initTcpListeners();
  410. connectTcp();
  411. } catch (error) {
  412. console.error("建立连接并设置监听:", error);
  413. uni.showToast({
  414. title: "建立连接并设置监听",
  415. icon: "none",
  416. duration: 1500,
  417. });
  418. }
  419. };
  420. const sendStopTimeData = () => {
  421. disconnectTcp();
  422. removeTcpListeners();
  423. };
  424. // 确定股票数据的颜色方法
  425. const confirmStockColor = (price, lastDayStockClosePrice) => {
  426. if (typeof lastDayStockClosePrice === "undefined") {
  427. if (price == 0) {
  428. return "price-none";
  429. } else if (price > 0) {
  430. return "price-up";
  431. } else {
  432. return "price-down";
  433. }
  434. } else {
  435. if (price == lastDayStockClosePrice) {
  436. return "price-none";
  437. } else if (price > lastDayStockClosePrice) {
  438. return "price-up";
  439. } else {
  440. return "price-down";
  441. }
  442. }
  443. };
  444. // 股票K线类型方法
  445. const selectKlineTab = (tabId) => {
  446. klineTab.value = tabId;
  447. if (klineTab.value == 1) {
  448. sendTcpMessage("init_real_time");
  449. }
  450. initCanvas();
  451. // startAddDataTimer();
  452. };
  453. // 返回按钮
  454. const backToHomepage = () => {
  455. const pages = getCurrentPages();
  456. if (pages.length > 1) {
  457. uni.navigateBack();
  458. } else {
  459. // 如果没有上一页,跳转到首页
  460. uni.reLaunch({
  461. url: "/pages/home/home",
  462. });
  463. }
  464. };
  465. const openStockDetail = () => {
  466. isStockDetail.value = true;
  467. };
  468. const closeStockDetail = () => {
  469. isStockDetail.value = false;
  470. };
  471. const openMoreTabs = () => {
  472. isMoreTabs.value = true;
  473. };
  474. const closeMoreTabs = () => {
  475. isMoreTabs.value = false;
  476. };
  477. const selectMoreTab = (value) => {
  478. selectKlineTab(value);
  479. };
  480. const chooseTabName = () => {
  481. if (klineTab.value === 5) {
  482. return "1分";
  483. } else if (klineTab.value === 6) {
  484. return "5分";
  485. } else if (klineTab.value === 7) {
  486. return "15分";
  487. } else if (klineTab.value === 8) {
  488. return "30分";
  489. } else if (klineTab.value === 9) {
  490. return "60分";
  491. } else if (klineTab.value === 10) {
  492. return "季K";
  493. } else if (klineTab.value === 11) {
  494. return "年K";
  495. }
  496. };
  497. // 画布对象
  498. const canvasWidth = ref(100);
  499. const canvasHeight = ref(100);
  500. const CANVAS_BACKGROUND_COLOR = "#fff";
  501. const TEXT_COLOR1 = "#000";
  502. const TEXT_COLOR2 = "red";
  503. const LINE_COLOR = "#e5e5e5";
  504. const timeChartObject = ref({
  505. min: 0,
  506. max: 0,
  507. });
  508. const klineChartObject = ref({
  509. min: 0,
  510. max: 0,
  511. });
  512. const chartRange = ref();
  513. // 开盘时间
  514. const openTime = "09:30";
  515. // 收盘时间
  516. const closeTime = "15:00";
  517. const ctx = ref(null);
  518. const dynamicCtx = ref(null); // 动态Canvas上下文
  519. const crosshairCtx = ref(null); // 十字准心Canvas上下文
  520. const pixelRatio = ref(1);
  521. // 触屏对象
  522. const touchState = reactive({
  523. startX: 0,
  524. startY: 0,
  525. isMoving: false,
  526. moveDistance: 0,
  527. moveThreshold: 10, //移动阈值(用于判断是否在点击)
  528. scale: 1, // 当前缩放比例
  529. minScale: 0.3, // 最小缩放比例
  530. maxScale: 5, // 最大缩放比例
  531. baseVisibleCount: 40, // 基准可见K线数量
  532. offset: 0, // 数据偏移量
  533. isZooming: false, // 是否正在缩放
  534. initialDistance: 0, // 初始两指距离
  535. initialScale: 1, // 初始缩放比例
  536. });
  537. // 计算当前可见的K线数量
  538. const visibleCount = computed(() => {
  539. return Math.floor(touchState.baseVisibleCount / touchState.scale);
  540. });
  541. // 计算当前显示的数据范围
  542. const visibleDataRange = computed(() => {
  543. const start = Math.max(0, klineData.value.length - visibleCount.value - touchState.offset);
  544. const end = Math.min(klineData.value.length, start + visibleCount.value);
  545. return {
  546. start,
  547. end,
  548. };
  549. });
  550. // 获取当前可见的数据
  551. const visibleKlineData = computed(() => {
  552. const { start, end } = visibleDataRange.value;
  553. return klineData.value.slice(start, end);
  554. });
  555. // 计算两点之间的距离
  556. const getDistance = (touch1, touch2) => {
  557. const dx = touch1.x - touch2.x;
  558. const dy = touch1.y - touch2.y;
  559. return Math.sqrt(dx * dx + dy * dy);
  560. };
  561. // 十字准线相关状态
  562. const crosshair = reactive({
  563. show: false,
  564. x: 0,
  565. y: 0,
  566. currentData: null,
  567. snapToData: true,
  568. });
  569. // 绘制网格和坐标轴
  570. const grid = [
  571. {
  572. top: 20,
  573. bottom: canvasHeight.value * 0.5,
  574. left: 5,
  575. right: 5,
  576. lineColor: LINE_COLOR,
  577. lineWidth: 1,
  578. horizontalLineNum: 5,
  579. verticalLineNum: 5,
  580. },
  581. {
  582. top: canvasHeight.value * 0.5 + 20,
  583. bottom: 20,
  584. left: 5,
  585. right: 5,
  586. lineColor: LINE_COLOR,
  587. lineWidth: 1,
  588. horizontalLineNum: 3,
  589. verticalLineNum: 5,
  590. },
  591. ];
  592. // 绘制网格和坐标轴
  593. const dayGrid = [
  594. {
  595. top: 20,
  596. bottom: canvasHeight.value * 0.5,
  597. left: 5,
  598. right: 5,
  599. lineColor: LINE_COLOR,
  600. lineWidth: 1,
  601. horizontalLineNum: 5,
  602. verticalLineNum: 2,
  603. },
  604. {
  605. top: canvasHeight.value * 0.5 + 20,
  606. bottom: 20,
  607. left: 5,
  608. right: 5,
  609. lineColor: LINE_COLOR,
  610. lineWidth: 1,
  611. horizontalLineNum: 3,
  612. verticalLineNum: 2,
  613. },
  614. {
  615. top: canvasHeight.value * 0.5 + 20,
  616. bottom: 20,
  617. left: 5,
  618. right: 5,
  619. lineColor: LINE_COLOR,
  620. lineWidth: 1,
  621. horizontalLineNum: 3,
  622. verticalLineNum: 2,
  623. },
  624. ];
  625. // 工具函数
  626. const utils = {
  627. // 格式化价格
  628. formatPrice(price) {
  629. return price.toFixed(2);
  630. },
  631. // 计算数据范围
  632. calculateDataRange(data, key) {
  633. if (!data || data.length === 0) {
  634. return {
  635. min: 0,
  636. max: 0,
  637. };
  638. }
  639. const values = data.map((item) => item[key]);
  640. return {
  641. min: Math.min(...values),
  642. max: Math.max(...values),
  643. };
  644. },
  645. // 计算标签
  646. calculateLabel(data, type = 2, preClosePrice = 0, key, num) {
  647. let label = [];
  648. if (key === "price") {
  649. // 分时价格区间
  650. if (type == 1) {
  651. const priceRange = utils.calculateDataRange(data, "price");
  652. const theMost = Math.max(priceRange.max - preClosePrice, preClosePrice - priceRange.min);
  653. const mid = (num - 1) / 2;
  654. // 计算分时价格标签
  655. label[mid] = {
  656. value: utils.formatPrice(preClosePrice),
  657. ratio: utils.formatPrice(0) + "%",
  658. };
  659. for (let i = 0; i < mid; i++) {
  660. label[i] = {
  661. value: utils.formatPrice(preClosePrice + (theMost * (mid - i)) / mid),
  662. ratio: utils.formatPrice((100 * (theMost * (mid - i))) / mid / preClosePrice) + "%",
  663. };
  664. label[num - 1 - i] = {
  665. value: utils.formatPrice(preClosePrice - (theMost * (mid - i)) / mid),
  666. ratio: utils.formatPrice((-1 * 100 * (theMost * (mid - i))) / mid / preClosePrice) + "%",
  667. };
  668. }
  669. chartRange.value.push({
  670. max: preClosePrice + theMost,
  671. min: preClosePrice - theMost,
  672. });
  673. // timeChartObject.value.max = preClosePrice + theMost;
  674. // timeChartObject.value.min = preClosePrice - theMost;
  675. return label;
  676. } else {
  677. const highPriceRange = utils.calculateDataRange(data, "high");
  678. const lowPriceRange = utils.calculateDataRange(data, "low");
  679. const priceRange = {
  680. max: highPriceRange.max * 1.01,
  681. min: lowPriceRange.min * 0.99,
  682. };
  683. const priceDiff = priceRange.max - priceRange.min;
  684. for (let i = 0; i < num; ++i) {
  685. label[i] = {
  686. value: utils.formatPrice(priceRange.max - (i * priceDiff) / (num - 1)),
  687. };
  688. }
  689. chartRange.value.push(priceRange);
  690. // klineChartObject.value.max = highPriceRange.max * 1.01;
  691. // klineChartObject.value.min = lowPriceRange.min * 0.99;
  692. return label;
  693. }
  694. } else if (key === "volume") {
  695. const volumeRange = utils.calculateDataRange(data, "volume");
  696. chartRange.value.push({
  697. max: volumeRange.max,
  698. min: 0,
  699. });
  700. label[0] = {
  701. value: utils.formatPrice(volumeRange.max),
  702. };
  703. label[1] = {
  704. value: utils.formatPrice(0),
  705. };
  706. return label;
  707. }
  708. return null;
  709. },
  710. // 线性插值
  711. lerp(start, end, factor) {
  712. return start + (end - start) * factor;
  713. },
  714. // 根据X坐标找到最近的数据点
  715. findNearestDataPoint(x, pointLen, data, grid, offset) {
  716. if (!data.length) return null;
  717. const width = canvasWidth.value;
  718. const height = canvasHeight.value;
  719. // 计算每个数据点的X坐标间隔
  720. // 倒推 const x=5+(index*(width-10)/pointLen) 已知x求index
  721. const xStep = width - grid[0].left - grid[0].right;
  722. // 计算触摸点对应的数据索引
  723. const touchX = (x - grid[0].left - offset) * pointLen;
  724. let nearestIndex = Math.round(touchX / xStep);
  725. let dataX;
  726. // 确保索引在有效范围内
  727. if (nearestIndex >= 0 && nearestIndex <= data.length - 1) {
  728. dataX = offset + grid[0].left + (nearestIndex * (width - grid[0].left - grid[0].right)) / pointLen;
  729. } else {
  730. dataX = x;
  731. }
  732. nearestIndex = Math.max(0, Math.min(nearestIndex, data.length - 1));
  733. return {
  734. ...data[nearestIndex],
  735. index: nearestIndex,
  736. x: dataX,
  737. };
  738. },
  739. // 根据Y坐标计算价格
  740. calculatePriceFromY(y, data, grid) {
  741. if (!data.length) return 0;
  742. const width = canvasWidth.value;
  743. const height = canvasHeight.value;
  744. // 上下边距1
  745. const topPadding1 = 20;
  746. const bottomPadding1 = height * 0.4;
  747. // 上下边距2
  748. const topPadding2 = height - bottomPadding1 + 40;
  749. const bottomPadding2 = 5;
  750. // 左右边距
  751. const verticalPadding = 5;
  752. let chartY;
  753. let price;
  754. for (let i = 0; i < grid.length; i++) {
  755. if (y >= grid[i].top && y <= height - grid[i].bottom) {
  756. const chartDiff = chartRange.value[i].max - chartRange.value[i].min;
  757. chartY = y - grid[i].top;
  758. price = chartRange.value[i].max - (chartY / (height - grid[i].bottom - grid[i].top)) * chartDiff;
  759. break;
  760. }
  761. }
  762. // if (y >= topPadding1 && y <= height - bottomPadding1) {
  763. // const priceDiff = priceRange.max - priceRange.min;
  764. // chartY = y - topPadding1;
  765. // price = priceRange.max - (chartY / (height - topPadding1 - bottomPadding1)) * priceDiff;
  766. // } else if (y >= topPadding2 && y <= height - bottomPadding2) {
  767. // const volumeRange = utils.calculateDataRange(data, "volume");
  768. // const volumeDiff = volumeRange.max - 0;
  769. // chartY = y - topPadding2;
  770. // price = volumeRange.max - (chartY / (height - topPadding2 - bottomPadding2)) * volumeDiff;
  771. // }
  772. return price;
  773. },
  774. // 股市数值格式化方法
  775. formatStockNumber(value, decimalPlaces = 2) {
  776. const num = Number(value);
  777. if (isNaN(num)) return "0";
  778. const absNum = Math.abs(num);
  779. const sign = num < 0 ? "-" : "";
  780. if (absNum >= 1000000000000) {
  781. // 万亿级别
  782. return sign + (absNum / 1000000000000).toFixed(decimalPlaces) + "万亿";
  783. } else if (absNum >= 100000000) {
  784. // 亿级别
  785. return sign + (absNum / 100000000).toFixed(decimalPlaces) + "亿";
  786. } else if (absNum >= 10000) {
  787. // 万级别
  788. return sign + (absNum / 10000).toFixed(decimalPlaces) + "万";
  789. } else {
  790. // 小于万的直接显示
  791. return sign + absNum.toFixed(decimalPlaces);
  792. }
  793. },
  794. };
  795. let text = [
  796. [
  797. {
  798. name: "领先",
  799. value: "暂无数据",
  800. color: "red",
  801. },
  802. {
  803. name: "价",
  804. value: utils.formatPrice(stockInformation.value.currentPrice),
  805. color: "black",
  806. },
  807. ],
  808. [
  809. {
  810. name: "量",
  811. value: utils.formatStockNumber(stockInformation.value.volume),
  812. color: "green",
  813. },
  814. {
  815. name: "额",
  816. value: "暂无数据",
  817. color: "black",
  818. },
  819. ],
  820. ];
  821. // 示例数据
  822. const timeData = ref([]);
  823. const klineData = ref([]);
  824. const initCanvas = async () => {
  825. try {
  826. crosshair.show = false;
  827. grid[0].bottom = canvasHeight.value * 0.4;
  828. grid[1].top = canvasHeight.value * 0.6 + 30;
  829. dayGrid[0].top = 20;
  830. dayGrid[0].bottom = canvasHeight.value * 0.6;
  831. dayGrid[1].top = canvasHeight.value * 0.4 + 30;
  832. dayGrid[1].bottom = canvasHeight.value * 0.3;
  833. dayGrid[2].top = canvasHeight.value * 0.7 + 20;
  834. dayGrid[2].bottom = 20;
  835. // 等待DOM更新
  836. await nextTick();
  837. ctx.value = uni.createCanvasContext("stockChart", instance.proxy);
  838. // 初始化动态数据Canvas上下文
  839. dynamicCtx.value = uni.createCanvasContext("dynamicCanvas", instance.proxy);
  840. // 初始化十字准心Canvas上下文
  841. crosshairCtx.value = uni.createCanvasContext("crosshairCanvas", instance.proxy);
  842. if (ctx.value) {
  843. // 设置Canvas的内部绘图区域尺寸
  844. ctx.value.canvas = {
  845. width: canvasWidth.value,
  846. height: canvasHeight.value,
  847. };
  848. } else {
  849. console.warn("Canvas上下文未初始化,跳过绘制");
  850. }
  851. // 确保Canvas上下文知道正确的尺寸
  852. if (dynamicCtx.value) {
  853. // 设置Canvas的内部绘图区域尺寸
  854. dynamicCtx.value.canvas = {
  855. width: canvasWidth.value,
  856. height: canvasHeight.value,
  857. };
  858. } else {
  859. console.warn("动态Canvas上下文未初始化,跳过绘制");
  860. }
  861. if (crosshairCtx.value) {
  862. // 设置Canvas的内部绘图区域尺寸
  863. crosshairCtx.value.canvas = {
  864. width: canvasWidth.value,
  865. height: canvasHeight.value,
  866. };
  867. } else {
  868. console.warn("十字准心Canvas上下文未初始化,跳过绘制");
  869. }
  870. // 等待DOM更新
  871. await nextTick();
  872. console.log("ctx", ctx.value);
  873. drawChart();
  874. } catch (error) {
  875. console.error("初始化Canvas失败:", error);
  876. }
  877. };
  878. // 绘制图表主函数-设置基础数据
  879. const drawChart = () => {
  880. if (!ctx.value || !dynamicCtx.value || !crosshairCtx.value) {
  881. console.warn("Canvas上下文未初始化,跳过绘制");
  882. return;
  883. }
  884. const data = klineTab.value == 1 ? timeData.value : klineData.value;
  885. chartRange.value = [];
  886. // 清除画布
  887. // HCharts.setCanvasColor(ctx.value, width, height, CANVAS_BACKGROUND_COLOR);
  888. // HCharts.setCanvasColor(dynamicCtx.value, width, height, CANVAS_BACKGROUND_COLOR);
  889. // HCharts.setCanvasColor(crosshairCtx.value, width, height, CANVAS_BACKGROUND_COLOR);
  890. // 根据当前标签绘制对应图表
  891. if (klineTab.value == 1) {
  892. console.log("stockInfomaton.lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
  893. // 设置标签样式
  894. const label = [
  895. {
  896. text: utils.calculateLabel(data, 1, stockInformation.value.lastDayStockClosePrice, "price", grid[0].horizontalLineNum),
  897. color: [TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR2],
  898. fontSize: 12,
  899. lineStyle: ["solid", "solid", "solid", "solid", "solid"],
  900. onlyTwo: false,
  901. },
  902. {
  903. text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "volume", grid[1].horizontalLineNum),
  904. color: [TEXT_COLOR1, TEXT_COLOR1],
  905. lineStyle: ["solid", "solid", "solid"],
  906. fontSize: 12,
  907. onlyTwo: true,
  908. },
  909. ];
  910. // 把label加进grid中
  911. grid[0].label = label[0];
  912. grid[1].label = label[1];
  913. drawTimeChart();
  914. } else {
  915. // 设置标签样式
  916. const label = [
  917. {
  918. text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "price", grid[0].horizontalLineNum),
  919. color: [TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR2],
  920. lineStyle: ["solid", "dash", "dash", "dash", "solid"],
  921. fontSize: 12,
  922. onlyTwo: false,
  923. },
  924. {
  925. text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "volume", grid[1].horizontalLineNum),
  926. color: [TEXT_COLOR1, TEXT_COLOR1],
  927. lineStyle: ["solid", "dash", "solid"],
  928. fontSize: 12,
  929. onlyTwo: true,
  930. },
  931. {
  932. text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "volume", grid[1].horizontalLineNum),
  933. color: [TEXT_COLOR1, TEXT_COLOR1],
  934. lineStyle: ["solid", "dash", "solid"],
  935. fontSize: 12,
  936. onlyTwo: true,
  937. },
  938. ];
  939. // 把label加进grid中
  940. dayGrid[0].label = label[0];
  941. dayGrid[1].label = label[1];
  942. dayGrid[2].label = label[2];
  943. // HCharts.drawGrid(ctx.value, canvasWidth.value, canvasHeight.value, dayGrid, data[0].date, data[data.length - 1].date);
  944. drawKLineChart();
  945. }
  946. // crosshairCtx.value.draw();
  947. };
  948. const throttledDrawChart = throttle(drawChart, 50);
  949. // // 绘制分时图
  950. const drawTimeChart = () => {
  951. drawCtxChart(grid);
  952. drawDynamicCtxChart();
  953. };
  954. // // 绘制K线图
  955. const drawKLineChart = () => {
  956. drawCtxChart(dayGrid);
  957. drawDynamicCtxChart();
  958. };
  959. const drawCtxChart = (paramGrid) => {
  960. // 检查Canvas上下文是否已初始化
  961. if (!ctx.value) {
  962. console.warn("Canvas上下文未初始化,跳过绘制");
  963. return;
  964. }
  965. // 绘制网格
  966. HCharts.drawGrid(ctx.value, canvasWidth.value, canvasHeight.value, paramGrid, openTime, closeTime);
  967. //执行绘制
  968. ctx.value.draw();
  969. };
  970. const drawDynamicCtxChart = () => {
  971. if (klineTab.value == 1) {
  972. //绘制价格曲线
  973. HCharts.drawPriceLine(dynamicCtx.value, canvasWidth.value, canvasHeight.value, timeData.value, grid, chartRange.value[0]);
  974. //绘制成交量
  975. HCharts.drawVolume(
  976. dynamicCtx.value,
  977. canvasWidth.value,
  978. canvasHeight.value,
  979. timeData.value,
  980. 2,
  981. 240,
  982. grid,
  983. {
  984. max: utils.calculateDataRange(timeData.value, "volume").max,
  985. min: 0,
  986. },
  987. 0
  988. );
  989. HCharts.drawAxisLabels(dynamicCtx.value, canvasWidth.value, canvasHeight.value, grid);
  990. HCharts.drawTopPriceDisplay(crosshairCtx.value, grid, text);
  991. } else {
  992. drawKLine(dynamicCtx.value);
  993. //绘制成交量
  994. HCharts.drawVolume(
  995. dynamicCtx.value,
  996. canvasWidth.value,
  997. canvasHeight.value,
  998. klineData.value,
  999. 2,
  1000. klineData.value.length,
  1001. dayGrid,
  1002. {
  1003. max: utils.calculateDataRange(klineData.value, "volume").max,
  1004. min: 0,
  1005. },
  1006. touchState.offset
  1007. );
  1008. //绘制成交量
  1009. HCharts.drawVolume(
  1010. dynamicCtx.value,
  1011. canvasWidth.value,
  1012. canvasHeight.value,
  1013. klineData.value,
  1014. 3,
  1015. klineData.value.length,
  1016. dayGrid,
  1017. {
  1018. max: utils.calculateDataRange(klineData.value, "volume").max,
  1019. min: 0,
  1020. },
  1021. touchState.offset
  1022. );
  1023. HCharts.drawAxisLabels(dynamicCtx.value, canvasWidth.value, canvasHeight.value, dayGrid);
  1024. // drawMovingAverage();
  1025. HCharts.drawTopPriceDisplay(crosshairCtx.value, dayGrid, []);
  1026. }
  1027. //执行绘制
  1028. dynamicCtx.value.draw(false);
  1029. crosshairCtx.value.draw();
  1030. };
  1031. const throttledDrawDynamicCtxChart = throttle(drawDynamicCtxChart, 50);
  1032. // const throttledDrawKLineChart = throttle(drawKLineChart, 10);
  1033. // 绘制K线
  1034. const drawKLine = (ctx) => {
  1035. const data = klineData.value;
  1036. if (!data.length) return;
  1037. const width = canvasWidth.value;
  1038. const height = canvasHeight.value;
  1039. // 计算价格范围
  1040. const priceRange = chartRange.value[0];
  1041. const priceDiff = priceRange.max - priceRange.min;
  1042. const areaWidth = (width - dayGrid[0].left - dayGrid[0].right) / data.length;
  1043. const candleWidth = areaWidth * 0.6;
  1044. data.forEach((item, index) => {
  1045. const x = touchState.offset + dayGrid[0].left + (index * (width - dayGrid[0].left - dayGrid[0].right)) / data.length;
  1046. // 计算坐标
  1047. const highY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.high - priceRange.min) / priceDiff);
  1048. const lowY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.low - priceRange.min) / priceDiff);
  1049. const openY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.open - priceRange.min) / priceDiff);
  1050. const closeY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.close - priceRange.min) / priceDiff);
  1051. // 判断涨跌
  1052. const isRise = item.close >= item.open;
  1053. const color = isRise ? "green" : "red";
  1054. // 绘制上下影线
  1055. ctx.setStrokeStyle(color);
  1056. ctx.setLineWidth(1);
  1057. ctx.beginPath();
  1058. ctx.moveTo(x, highY);
  1059. ctx.lineTo(x, lowY);
  1060. ctx.stroke();
  1061. // 绘制K线实体
  1062. const entityTop = isRise ? closeY : openY;
  1063. const entityBottom = isRise ? openY : closeY;
  1064. const entityHeight = Math.max(Math.abs(entityBottom - entityTop), 1);
  1065. ctx.setFillStyle(color);
  1066. ctx.fillRect(x - candleWidth / 2, entityTop, candleWidth, entityHeight);
  1067. });
  1068. };
  1069. // 绘制移动平均线
  1070. const drawMovingAverage = () => {
  1071. const data = klineData.value;
  1072. if (!data.length) return;
  1073. const ma5 = calculateMA(data, 5);
  1074. const ma10 = calculateMA(data, 10);
  1075. drawMALine(ma5, "#fadb14", "MA5"); // 黄色
  1076. drawMALine(ma10, "#eb2f96", "MA10"); // 粉色
  1077. };
  1078. // 计算移动平均线
  1079. const calculateMA = (data, period) => {
  1080. const result = [];
  1081. for (let i = period - 1; i < data.length; i++) {
  1082. let sum = 0;
  1083. for (let j = 0; j < period; j++) {
  1084. sum += data[i - j].close;
  1085. }
  1086. result.push({
  1087. index: i,
  1088. value: sum / period,
  1089. });
  1090. }
  1091. return result;
  1092. };
  1093. // 绘制均线
  1094. const drawMALine = (maData, color, label) => {
  1095. if (!maData.length) return;
  1096. const width = canvasWidth.value;
  1097. const height = canvasHeight.value;
  1098. const data = klineData.value;
  1099. // 计算价格范围
  1100. const highs = data.map((item) => item.high);
  1101. const lows = data.map((item) => item.low);
  1102. const priceRange = chartRange.value[0];
  1103. const priceDiff = priceRange.max - priceRange.min;
  1104. ctx.value.setStrokeStyle(color);
  1105. ctx.value.setLineWidth(1.5);
  1106. ctx.value.beginPath();
  1107. maData.forEach((point, idx) => {
  1108. const x = 40 + (point.index * (width - 60)) / (data.length - 1);
  1109. const y = 20 + (height - 60) * (1 - (point.value - priceRange.min) / priceDiff);
  1110. if (idx === 0) {
  1111. ctx.value.moveTo(x, y);
  1112. } else {
  1113. ctx.value.lineTo(x, y);
  1114. }
  1115. });
  1116. ctx.value.stroke();
  1117. // 绘制图例
  1118. if (maData.length > 0) {
  1119. const lastPoint = maData[maData.length - 1];
  1120. const x = 40 + (lastPoint.index * (width - 60)) / (data.length - 1);
  1121. const y = 20 + (height - 60) * (1 - (lastPoint.value - priceRange.min) / priceDiff);
  1122. HCharts.drawText(ctx.value`${label}: ${utils.formatPrice(lastPoint.value)}`, x + 10, y - 5, 12, color);
  1123. }
  1124. };
  1125. // 触摸事件处理
  1126. const touchStart = (e) => {
  1127. e.preventDefault(); // 阻止页面滚动等默认行为
  1128. if (typeof e.touches[1] === "undefined") {
  1129. touchState.startX = e.touches[0].x;
  1130. touchState.startY = e.touches[0].y;
  1131. touchState.isMoving = false;
  1132. } else if (typeof e.touches[1] !== "undefined" && !crosshair.show) {
  1133. // touchState.startX1 = e.touches[0].x;
  1134. // touchState.startY1 = e.touches[0].y;
  1135. // touchState.startX2 = e.touches[1].x;
  1136. // touchState.startY2 = e.touches[1].y;
  1137. // touchState.isMoving = false;
  1138. }
  1139. };
  1140. const touchMove = (e) => {
  1141. touchState.isMoving = true;
  1142. // 计算移动距离
  1143. const currentX = e.touches[0].x;
  1144. const currentY = e.touches[0].y;
  1145. const deltaX = currentX - touchState.startX;
  1146. const deltaY = currentY - touchState.startY;
  1147. touchState.moveDistance = Math.max(touchState.moveDistance, Math.sqrt(deltaX * deltaX + deltaY * deltaY));
  1148. if (crosshair.show) {
  1149. if (isInChartArea(currentX, currentY, klineTab.value === 1 ? grid : dayGrid)) {
  1150. throttledUpdateCrosshair(currentX, currentY);
  1151. } else {
  1152. // 如果移出图表区域,隐藏十字准线
  1153. // crosshair.show = false;
  1154. if (klineTab.value === 1) {
  1155. HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, grid, crosshair, text);
  1156. } else {
  1157. HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, dayGrid, crosshair, []);
  1158. }
  1159. }
  1160. } else {
  1161. if (klineTab.value === 2) {
  1162. // if(currentY)
  1163. if (currentX < touchState.startX) {
  1164. touchState.offset += currentX - touchState.startX;
  1165. throttledDrawDynamicCtxChart();
  1166. touchState.startX = currentX;
  1167. } else {
  1168. touchState.offset += currentX - touchState.startX;
  1169. throttledDrawDynamicCtxChart();
  1170. touchState.startX = currentX;
  1171. }
  1172. if (canvasWidth.value + touchState.offset < 30) {
  1173. touchState.offset = 0;
  1174. }
  1175. }
  1176. }
  1177. };
  1178. const touchEnd = (e) => {
  1179. // 触摸结束,隐藏十字准线
  1180. if (touchState.moveDistance < touchState.moveThreshold) {
  1181. // 移动距离小于阈值,认为是点击事件
  1182. crosshair.show = !crosshair.show;
  1183. }
  1184. if (crosshair.show) {
  1185. const currentX = e.changedTouches[0].x;
  1186. const currentY = e.changedTouches[0].y;
  1187. if (isInChartArea(currentX, currentY, klineTab.value === 1 ? grid : dayGrid)) {
  1188. throttledUpdateCrosshair(currentX, currentY);
  1189. }
  1190. }
  1191. if (klineTab.value === 1) {
  1192. HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, grid, crosshair, text);
  1193. } else {
  1194. HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, dayGrid, crosshair, []);
  1195. }
  1196. // 重置移动距离
  1197. touchState.moveDistance = 0;
  1198. };
  1199. // 检查坐标是否在图表区域内
  1200. const isInChartArea = (x, y, grid) => {
  1201. const width = canvasWidth.value;
  1202. const height = canvasHeight.value;
  1203. if (x < grid[0].left || x > width - grid[0].right) return false;
  1204. for (let i = 0; i < grid.length; i++) {
  1205. if (y >= grid[i].top && y <= height - grid[i].bottom) return true;
  1206. }
  1207. return false;
  1208. };
  1209. // 更新十字准线位置和数据
  1210. const updateCrosshair = (x, y) => {
  1211. if (!crosshair.show) return;
  1212. // 更新Y坐标以匹配实际价格
  1213. const data = klineTab.value === 1 ? timeData.value : klineData.value;
  1214. const width = canvasWidth.value;
  1215. const height = canvasHeight.value;
  1216. crosshair.x = x;
  1217. crosshair.y = y;
  1218. const nearestData = utils.findNearestDataPoint(x, klineTab.value === 1 ? 240 : data.length, data, klineTab.value === 1 ? grid : dayGrid, klineTab.value === 1 ? 0 : touchState.offset);
  1219. crosshair.x = nearestData.x;
  1220. stockInformation.value.currentPrice = nearestData.price || nearestData.close;
  1221. stockInformation.value.volume = nearestData.volume;
  1222. // 涨跌额度
  1223. if (klineTab.value == 1) {
  1224. stockInformation.value.currentValue = stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice;
  1225. stockInformation.value.currentRatio = ((stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice) / stockInformation.value.lastDayStockClosePrice) * 100;
  1226. } else {
  1227. stockInformation.value.openPrice = nearestData.open;
  1228. stockInformation.value.highPrice = nearestData.high;
  1229. stockInformation.value.lowPrice = nearestData.low;
  1230. if (nearestData.index != 0) {
  1231. stockInformation.value.lastDayStockClosePrice = data[nearestData.index - 1].close;
  1232. stockInformation.value.currentValue = stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice;
  1233. stockInformation.value.currentRatio = ((stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice) / stockInformation.value.lastDayStockClosePrice) * 100;
  1234. }
  1235. }
  1236. if (klineTab.value === 1 && crosshair.snapToData) {
  1237. // 吸附到最近的数据点
  1238. if (nearestData) {
  1239. crosshair.currentData = nearestData;
  1240. const priceDiff = chartRange.value[0].max - chartRange.value[0].min;
  1241. crosshair.y = grid[0].top + (height - grid[0].bottom - grid[0].top) * (1 - (nearestData.price - chartRange.value[0].min) / priceDiff);
  1242. }
  1243. } else {
  1244. // 自由移动模式
  1245. const currentPrice = utils.calculatePriceFromY(y, data, klineTab.value === 1 ? grid : dayGrid);
  1246. crosshair.currentData = {
  1247. ...nearestData,
  1248. price: currentPrice,
  1249. };
  1250. }
  1251. if (crosshair?.currentData?.volume) {
  1252. text[0][1].value = crosshair.currentData.price;
  1253. text[1][0].value = crosshair.currentData.volume;
  1254. }
  1255. // 只重绘十字准心,不重绘整个图表
  1256. if (klineTab.value === 1) {
  1257. HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, grid, crosshair, text);
  1258. } else {
  1259. HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, dayGrid, crosshair, []);
  1260. }
  1261. };
  1262. const throttledUpdateCrosshair = throttle(updateCrosshair, 10);
  1263. // 缩放控制函数
  1264. const zoomIn = () => {
  1265. touchState.scale = Math.min(touchState.maxScale, touchState.scale * 1.2);
  1266. drawChart();
  1267. };
  1268. const zoomOut = () => {
  1269. touchState.scale = Math.max(touchState.minScale, touchState.scale / 1.2);
  1270. drawChart();
  1271. };
  1272. // 监听数据变化
  1273. // watch([timeData, klineData], () => {
  1274. // console.log("数据变化");
  1275. // drawChart();
  1276. // });
  1277. watch(klineTab, () => {
  1278. console.log("标签页变化");
  1279. drawChart();
  1280. });
  1281. // 初始化TCP监听器
  1282. const initTcpListeners = () => {
  1283. // 创建连接状态监听器并保存引用
  1284. connectionListener.value = (status, result) => {
  1285. tcpConnected.value = status === "connected";
  1286. console.log("TCP连接状态变化:", status, tcpConnected.value);
  1287. // 显示连接状态提示
  1288. uni.showToast({
  1289. title: status === "connected" ? "TCP连接成功" : "TCP连接断开",
  1290. icon: status === "connected" ? "success" : "none",
  1291. duration: 2000,
  1292. });
  1293. if (status === "connected") {
  1294. if (klineTab.value == 1) {
  1295. sendTcpMessage("init_real_time");
  1296. }
  1297. }
  1298. };
  1299. // 创建消息监听器并保存引用
  1300. messageListener.value = (type, message, parsedArray) => {
  1301. const messageObj = {
  1302. type: type,
  1303. content: message,
  1304. parsedArray: parsedArray,
  1305. timestamp: new Date().toLocaleTimeString(),
  1306. direction: "received",
  1307. };
  1308. console.log("0000");
  1309. tcpMessages.value.push(messageObj);
  1310. // console.log('收到TCP消息:', messageObj)
  1311. console.log("home开始调用parseStockData", messageObj);
  1312. // 解析股票数据
  1313. parseStockData(message);
  1314. };
  1315. // 注册监听器
  1316. tcpConnection.onConnectionChange(connectionListener.value);
  1317. tcpConnection.onMessage(messageListener.value);
  1318. };
  1319. // 连接TCP服务器
  1320. const connectTcp = () => {
  1321. console.log("开始连接TCP服务器...");
  1322. tcpConnection.connect();
  1323. };
  1324. // 断开TCP连接
  1325. const disconnectTcp = () => {
  1326. console.log("断开TCP连接...");
  1327. tcpConnection.disconnect();
  1328. tcpConnected.value = false;
  1329. };
  1330. // 发送TCP消息
  1331. const sendTcpMessage = (command) => {
  1332. let messageData;
  1333. switch (command) {
  1334. // 实时行情推送
  1335. case "real_time":
  1336. messageData = {
  1337. command: "real_time",
  1338. stock_code: "SH.000001",
  1339. };
  1340. break;
  1341. // 初始化获取行情历史数据
  1342. case "init_real_time":
  1343. messageData = {
  1344. command: "init_real_time",
  1345. stock_code: "SH.000001",
  1346. };
  1347. break;
  1348. case "stop_real_time":
  1349. messageData = {
  1350. command: "stop_real_time",
  1351. };
  1352. break;
  1353. // 股票列表
  1354. case "stock_list":
  1355. messageData = {
  1356. command: "stock_list",
  1357. };
  1358. break;
  1359. case "daily_data":
  1360. messageData = {
  1361. command: "daily_data",
  1362. stock_code: "GBPAUD.FXCM",
  1363. start_date: "20251001",
  1364. end_date: "20251023",
  1365. };
  1366. break;
  1367. case "weekly_data":
  1368. messageData = {
  1369. command: "weekly_data",
  1370. stock_code: "000001.SZ",
  1371. start_date: "20251001",
  1372. end_date: "20251023",
  1373. };
  1374. break;
  1375. case "daily_one_minutes_data":
  1376. messageData = {
  1377. command: "daily_one_minutes_data",
  1378. stock_code: "000001.SZ",
  1379. };
  1380. break;
  1381. case "daily_five_minutes_data":
  1382. messageData = {
  1383. command: "daily_five_minutes_data",
  1384. stock_code: "000001.SZ",
  1385. };
  1386. break;
  1387. case "daily_fifteen_minutes_data":
  1388. messageData = {
  1389. command: "daily_fifteen_minutes_data",
  1390. stock_code: "000001.SZ",
  1391. };
  1392. break;
  1393. case "daily_thirty_minutes_data":
  1394. messageData = {
  1395. command: "daily_thirty_minutes_data",
  1396. stock_code: "000001.SZ",
  1397. };
  1398. break;
  1399. case "daily_sixty_minutes_data":
  1400. messageData = {
  1401. command: "daily_sixty_minutes_data",
  1402. stock_code: "000001.SZ",
  1403. };
  1404. break;
  1405. case "batch_real_time":
  1406. messageData = {
  1407. command: "batch_real_time",
  1408. stock_codes: ["SH.000001", "SH.000002", "SH.000003", "SH.000004", "SH.000005"],
  1409. };
  1410. break;
  1411. case "help":
  1412. messageData = {
  1413. command: "help",
  1414. };
  1415. break;
  1416. }
  1417. if (!messageData) {
  1418. uni.showToast({
  1419. title: "命令不存在",
  1420. icon: "none",
  1421. duration: 1500,
  1422. });
  1423. return;
  1424. } else {
  1425. try {
  1426. // 发送消息
  1427. const success = tcpConnection.send(messageData);
  1428. if (success) {
  1429. console.log("home发送TCP消息:", messageData);
  1430. uni.showToast({
  1431. title: "消息发送成功",
  1432. icon: "success",
  1433. duration: 1500,
  1434. });
  1435. }
  1436. } catch (error) {
  1437. console.error("发送TCP消息时出错:", error);
  1438. uni.showToast({
  1439. title: "消息发送失败",
  1440. icon: "none",
  1441. duration: 1500,
  1442. });
  1443. }
  1444. }
  1445. };
  1446. // 清空消息记录
  1447. const clearTcpMessages = () => {
  1448. tcpMessages.value = [];
  1449. uni.showToast({
  1450. title: "消息记录已清空",
  1451. icon: "success",
  1452. duration: 1500,
  1453. });
  1454. };
  1455. // 获取TCP连接状态
  1456. const getTcpStatus = () => {
  1457. const status = tcpConnection.getConnectionStatus();
  1458. uni.showModal({
  1459. title: "TCP连接状态",
  1460. content: `当前状态: ${status ? "已连接" : "未连接"}\n消息数量: ${tcpMessages.value.length}`,
  1461. showCancel: false,
  1462. });
  1463. };
  1464. let isMorePacket = false;
  1465. let receivedMessage;
  1466. // 解析TCP股票数据
  1467. const parseStockData = (message) => {
  1468. try {
  1469. console.log("进入parseStockData, message类型:", typeof message);
  1470. let parsedMessage;
  1471. // 如果isMorePacket是true,说明正在接受分包数据,无条件接收
  1472. // 如果message是字符串且以{开头,说明是JSON字符串,需要解析
  1473. // 如果不属于以上两种情况,说明是普通字符串,不预解析
  1474. if (message.includes("欢迎连接到股票数据服务器")) {
  1475. console.log("服务器命令列表,不予处理");
  1476. return;
  1477. }
  1478. if ((typeof message === "string" && message.includes("init_real_data_start")) || isMorePacket) {
  1479. if (typeof message === "string" && message.includes("init_real_data_start")) {
  1480. console.log("开始接受分包数据");
  1481. receivedMessage = "";
  1482. } else {
  1483. console.log("接收分包数据过程中");
  1484. }
  1485. isMorePacket = true;
  1486. receivedMessage += message;
  1487. // 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
  1488. if (receivedMessage.includes("init_real_data_complete")) {
  1489. console.log("接受分包数据结束");
  1490. isMorePacket = false;
  1491. console.log("展示数据", receivedMessage);
  1492. // 获取JSON字符串的开头和结尾的坐标
  1493. let jsonStartIndex = 0;
  1494. let jsonEndIndex = receivedMessage.indexOf("init_real_data_complete");
  1495. let jsonStartCount = 0;
  1496. let jsonEndCount = 0;
  1497. for (let i = 0; i < receivedMessage.length - 1; ++i) {
  1498. if (receivedMessage[i] == "{") {
  1499. jsonStartCount++;
  1500. if (jsonStartCount == 2) {
  1501. jsonStartIndex = i;
  1502. break;
  1503. }
  1504. }
  1505. }
  1506. for (let i = receivedMessage.indexOf("init_real_data_complete"); i >= 0; --i) {
  1507. if (receivedMessage[i] == "}" || i == jsonStartIndex) {
  1508. jsonEndCount++;
  1509. if (jsonEndCount == 1) {
  1510. jsonEndIndex = i;
  1511. break;
  1512. }
  1513. }
  1514. }
  1515. // 检查JSON字符串是否有效
  1516. if (jsonStartIndex >= jsonEndIndex) {
  1517. throw new Error("JSON字符串格式错误");
  1518. }
  1519. console.log("检测到JSON字符串,开始解析");
  1520. parsedMessage = JSON.parse(receivedMessage.substring(jsonStartIndex, jsonEndIndex + 1));
  1521. console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
  1522. if (parsedMessage.type === "daily_data") {
  1523. timeData.value = parsedMessage.data;
  1524. stockInformation.value.lastDayStockClosePrice = parsedMessage.pre_close;
  1525. console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
  1526. drawChart();
  1527. }
  1528. }
  1529. // 通过了JSON解析判断,说明返回的数据是需要的正确数据,进行股票实时数据检查
  1530. console.log("开始处理解析后的数据");
  1531. // 检查是否是股票数据(支持batch_data_chunk和batch_realtime_data两种类型)
  1532. if ((parsedMessage.type === "batch_data_chunk" || parsedMessage.type === "batch_realtime_data") && parsedMessage.data) {
  1533. console.log("开始更新TCP股票数据存储");
  1534. // 更新TCP股票数据存储
  1535. tcpStockData.value = {
  1536. count: parsedMessage.count || 0,
  1537. data: parsedMessage.data || {},
  1538. stock_count: parsedMessage.stock_count || 0,
  1539. timestamp: parsedMessage.timestamp || "",
  1540. type: parsedMessage.type || "",
  1541. };
  1542. // 获取第一个股票的数据用于显示
  1543. const stockCodes = Object.keys(parsedMessage.data);
  1544. if (stockCodes.length > 0) {
  1545. const firstStockCode = stockCodes[0];
  1546. // 检查数据结构
  1547. if (parsedMessage.data[firstStockCode] && Array.isArray(parsedMessage.data[firstStockCode]) && parsedMessage.data[firstStockCode].length > 0) {
  1548. const stockData = parsedMessage.data[firstStockCode][0]; // 取第一条数据
  1549. if (stockData && stockData.current_price !== undefined && stockData.pre_close !== undefined) {
  1550. // 计算涨跌幅
  1551. const changeValue = stockData.current_price - stockData.pre_close;
  1552. const changePercent = ((changeValue / stockData.pre_close) * 100).toFixed(2);
  1553. const changeSign = changeValue >= 0 ? "+" : "";
  1554. // 更新当前显示的股票信息
  1555. currentStockInfo.value = {
  1556. stock_name: stockData.stock_name || "未知股票",
  1557. current_price: stockData.current_price ? stockData.current_price.toFixed(2) : "0.00",
  1558. change: `${changeSign}${changePercent}%`,
  1559. change_value: changeValue,
  1560. change_percent: parseFloat(changePercent),
  1561. };
  1562. console.log("股票数据更新成功:", currentStockInfo.value);
  1563. }
  1564. }
  1565. }
  1566. } else {
  1567. console.log("不是batch_data_chunk或batch_realtime_data类型的消息,跳过处理");
  1568. }
  1569. } else {
  1570. // 没有通过JSON解析判断,说明不是需要的数据
  1571. console.log("不是需要的数据,不做处理");
  1572. }
  1573. } catch (error) {
  1574. console.error("解析TCP股票数据失败:", error.message);
  1575. console.error("错误详情:", error);
  1576. }
  1577. };
  1578. // 移除TCP监听器
  1579. const removeTcpListeners = () => {
  1580. if (connectionListener.value) {
  1581. tcpConnection.removeConnectionListener(connectionListener.value);
  1582. connectionListener.value = null;
  1583. console.log("已移除TCP连接状态监听器");
  1584. }
  1585. if (messageListener.value) {
  1586. tcpConnection.removeMessageListener(messageListener.value);
  1587. messageListener.value = null;
  1588. console.log("已移除TCP消息监听器");
  1589. }
  1590. };
  1591. // 定时器标识(用于清除定时器)
  1592. let timer = null;
  1593. let index = 0;
  1594. // 定时添加数据的函数
  1595. const startAddDataTimer = () => {
  1596. if (timer) {
  1597. console.log("存在旧定时器,卸载旧定时器");
  1598. clearInterval(timer);
  1599. }
  1600. console.log("开始定时任务");
  1601. // 每隔5秒执行一次
  1602. timer = setInterval(() => {
  1603. if (index < testTimeData.length) {
  1604. timeData.value.push(testTimeData[index]);
  1605. console.log("新增数据:", testTimeData[index]);
  1606. // 触发图表重新绘制
  1607. drawChart();
  1608. index++;
  1609. } else {
  1610. clearInterval(timer);
  1611. }
  1612. }, 2000); // 5000毫秒 = 5秒
  1613. };
  1614. onLoad((options) => {
  1615. console.log("页面接收到的参数:", options);
  1616. // 处理通过data参数传递的复杂对象
  1617. if (options.data) {
  1618. try {
  1619. const stockData = JSON.parse(decodeURIComponent(options.data));
  1620. console.log("解析的股票数据:", stockData);
  1621. // 更新stockInformation
  1622. if (stockData) {
  1623. stockInformation.value.stockName = stockData.stockName;
  1624. stockInformation.value.stockCode = stockData.stockCode;
  1625. }
  1626. } catch (error) {
  1627. console.error("解析股票数据失败:", error);
  1628. }
  1629. }
  1630. // 处理通过stockInformation参数传递的数据(兼容globalIndex.vue的传参方式)
  1631. if (options.stockInformation) {
  1632. try {
  1633. const stockData = JSON.parse(decodeURIComponent(options.stockInformation));
  1634. console.log("解析的股票信息:", stockData);
  1635. // 更新stockInformation
  1636. if (stockData) {
  1637. stockInformation.value = {
  1638. ...stockInformation.value,
  1639. ...stockData,
  1640. };
  1641. }
  1642. } catch (error) {
  1643. console.error("解析股票信息失败:", error);
  1644. }
  1645. }
  1646. // 处理简单参数
  1647. if (options.stockCode) {
  1648. stockInformation.value.stockCode = options.stockCode;
  1649. }
  1650. if (options.stockName) {
  1651. stockInformation.value.stockName = decodeURIComponent(options.stockName);
  1652. }
  1653. });
  1654. // 保存定时器,用于页面卸载时清理
  1655. onUnmounted(() => {
  1656. // disconnect();
  1657. if (timer) {
  1658. console.log("卸载定时器");
  1659. clearInterval(timer);
  1660. }
  1661. });
  1662. onMounted(async () => {
  1663. try {
  1664. console.log("步骤1: 初始化系统信息");
  1665. const systemInfo = uni.getSystemInfoSync();
  1666. pixelRatio.value = systemInfo.pixelRatio;
  1667. // 设置Canvas实际像素(考虑pixelRatio以获得高清效果)
  1668. // 1rpx = 设备屏幕宽度 / 750
  1669. const rpxToPx = systemInfo.windowWidth / 750;
  1670. const offsetHeight = (150 + 200 + 80 + 150 + 30) * rpxToPx; // 350rpx转换为px
  1671. const calculatedHeight = systemInfo.windowHeight - offsetHeight;
  1672. canvasWidth.value = systemInfo.windowWidth;
  1673. canvasHeight.value = Math.max(calculatedHeight, canvasHeight.value);
  1674. initTcpListeners();
  1675. await nextTick();
  1676. // 开始连接
  1677. startTcp();
  1678. if (timeData.value && timeData.value.length > 0) {
  1679. // 当前股价
  1680. stockInformation.value.currentPrice = timeData.value[timeData.value.length - 1].price;
  1681. // 涨跌额度
  1682. stockInformation.value.currentValue = stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice;
  1683. // 涨跌幅度
  1684. stockInformation.value.currentRatio = ((stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice) / stockInformation.value.lastDayStockClosePrice) * 100;
  1685. // 成交量
  1686. stockInformation.value.volume = timeData.value[timeData.value.length - 1].volume;
  1687. text[0][1].value = utils.formatPrice(stockInformation.value.currentPrice);
  1688. text[1][0].value = utils.formatStockNumber(stockInformation.value.volume);
  1689. } else {
  1690. console.warn("没有时间数据,跳过股票信息计算");
  1691. }
  1692. await nextTick();
  1693. initCanvas();
  1694. console.log("所有初始化步骤完成");
  1695. } catch (error) {
  1696. console.error("初始化过程中出现错误:", error);
  1697. }
  1698. });
  1699. </script>
  1700. <style>
  1701. .container {
  1702. width: 100%;
  1703. min-height: 100vh;
  1704. background-color: #f6f6f6;
  1705. }
  1706. .title-container {
  1707. width: 100%;
  1708. height: 150rpx;
  1709. background-color: white;
  1710. display: flex;
  1711. flex-direction: column;
  1712. justify-content: flex-end;
  1713. }
  1714. .title {
  1715. /* border: 1px solid #ff0000; */
  1716. position: relative;
  1717. /* 关键:作为绝对定位的父容器 */
  1718. width: 100%;
  1719. height: 80rpx;
  1720. display: flex;
  1721. justify-content: center;
  1722. align-items: center;
  1723. }
  1724. .back-homepage-btn {
  1725. margin-left: 40rpx;
  1726. margin-right: auto;
  1727. height: 30rpx;
  1728. width: 20rpx;
  1729. z-index: 1;
  1730. }
  1731. .mid-title {
  1732. position: absolute;
  1733. left: 0;
  1734. right: 0;
  1735. display: flex;
  1736. justify-content: center;
  1737. align-items: center;
  1738. }
  1739. .left-page {
  1740. height: 40rpx;
  1741. width: 40rpx;
  1742. }
  1743. .right-page {
  1744. height: 40rpx;
  1745. width: 40rpx;
  1746. }
  1747. .stock-id {
  1748. margin: 0rpx 40rpx;
  1749. display: flex;
  1750. flex-direction: column;
  1751. text-align: center;
  1752. }
  1753. .stock-name {
  1754. font-weight: bold;
  1755. }
  1756. .stock-code {
  1757. font-size: 0.8rem;
  1758. font-weight: bold;
  1759. color: #a1a1a1;
  1760. }
  1761. .search {
  1762. height: 40rpx;
  1763. width: 40rpx;
  1764. }
  1765. .more {
  1766. height: 100%;
  1767. display: flex;
  1768. justify-content: center;
  1769. align-items: center;
  1770. margin-right: 40rpx;
  1771. margin-left: 20rpx;
  1772. }
  1773. .body {
  1774. overflow: auto;
  1775. height: calc(100vh - 305rpx);
  1776. /* border: 1px solid red; */
  1777. }
  1778. .stock-information {
  1779. background-color: white;
  1780. width: 100%;
  1781. height: 200rpx;
  1782. margin: 10rpx 0rpx;
  1783. display: flex;
  1784. align-items: center;
  1785. position: relative;
  1786. /* 为伪元素定位做准备 */
  1787. }
  1788. /* 右下角黑色三角形 */
  1789. .stock-information::after {
  1790. content: "";
  1791. position: absolute;
  1792. bottom: 0;
  1793. right: 0;
  1794. width: 0;
  1795. height: 0;
  1796. border-left: 20rpx solid transparent;
  1797. border-bottom: 20rpx solid #6a6a6a;
  1798. }
  1799. .stock-detail-container {
  1800. position: absolute;
  1801. top: 100%;
  1802. /* 在父容器下方 */
  1803. left: 0;
  1804. /* 从左边开始 */
  1805. right: 0;
  1806. /* 到右边结束 */
  1807. width: 100%;
  1808. /* height: 300rpx; 使用固定高度替代calc,避免计算问题 */
  1809. height: calc(100vh - 515rpx);
  1810. display: flex;
  1811. flex-direction: column;
  1812. background-color: rgba(0, 0, 0, 0.6);
  1813. z-index: 100;
  1814. box-sizing: border-box;
  1815. pointer-events: auto;
  1816. }
  1817. .stock-detail {
  1818. border: 1px solid #cacaca;
  1819. width: 100%;
  1820. display: flex;
  1821. background-color: white;
  1822. padding: 10rpx 0;
  1823. }
  1824. .first-column,
  1825. .second-column {
  1826. width: 50%;
  1827. height: 100%;
  1828. display: flex;
  1829. flex-direction: column;
  1830. color: #6a6a6a;
  1831. font-size: 0.8rem;
  1832. }
  1833. .first-column-data,
  1834. .second-column-data {
  1835. display: flex;
  1836. justify-content: space-between;
  1837. align-items: center;
  1838. padding: 10rpx 40rpx;
  1839. }
  1840. .stock-detail-title {
  1841. color: #6a6a6a;
  1842. }
  1843. .stock-detail-value {
  1844. color: black;
  1845. }
  1846. .price-up {
  1847. color: #10b981;
  1848. }
  1849. .price-down {
  1850. color: #ef4444;
  1851. }
  1852. .price-none {
  1853. color: black;
  1854. }
  1855. .stock-current-data {
  1856. width: 30%;
  1857. height: 70%;
  1858. display: flex;
  1859. flex-direction: column;
  1860. }
  1861. .stock-current-price {
  1862. font-weight: bold;
  1863. font-size: 1.2rem;
  1864. width: 100%;
  1865. height: 50%;
  1866. display: flex;
  1867. justify-content: center;
  1868. align-items: center;
  1869. }
  1870. .stock-current-other {
  1871. width: 100%;
  1872. height: 50%;
  1873. display: flex;
  1874. font-weight: bold;
  1875. font-size: 0.6rem;
  1876. }
  1877. .stock-current-value {
  1878. width: 50%;
  1879. height: 100%;
  1880. display: flex;
  1881. justify-content: center;
  1882. align-items: center;
  1883. }
  1884. .stock-current-ratio {
  1885. width: 50%;
  1886. height: 100%;
  1887. display: flex;
  1888. justify-content: center;
  1889. align-items: center;
  1890. }
  1891. .stock-other-data {
  1892. height: 100%;
  1893. width: 70%;
  1894. display: flex;
  1895. flex-direction: column;
  1896. }
  1897. .first-line,
  1898. .second-line,
  1899. .third-line {
  1900. display: flex;
  1901. align-items: center;
  1902. width: 100%;
  1903. height: 33%;
  1904. /* font-weight: bold; */
  1905. font-size: 0.8rem;
  1906. }
  1907. .value {
  1908. margin-left: auto;
  1909. margin-right: 20rpx;
  1910. }
  1911. .high-price,
  1912. .volume,
  1913. .volume-ratio,
  1914. .low-price,
  1915. .amount,
  1916. .market-earn,
  1917. .open-price,
  1918. .turnover-ratio,
  1919. .market-value {
  1920. display: flex;
  1921. flex: 1;
  1922. }
  1923. .stock-chart {
  1924. width: 100%;
  1925. }
  1926. .stock-kline-tab {
  1927. display: flex;
  1928. width: 100%;
  1929. height: 80rpx;
  1930. /* border: 1px solid black; */
  1931. }
  1932. .tab-day,
  1933. .tab-month,
  1934. .tab-time,
  1935. .tab-week,
  1936. .tab-more,
  1937. .tab-setting {
  1938. display: flex;
  1939. justify-content: center;
  1940. align-items: center;
  1941. flex: 1;
  1942. font-size: 0.8rem;
  1943. color: #6a6a6a;
  1944. position: relative;
  1945. }
  1946. /* 向下小三角样式 */
  1947. .arrow-down {
  1948. width: 0;
  1949. height: 0;
  1950. border-left: 10rpx solid transparent;
  1951. border-right: 10rpx solid transparent;
  1952. border-bottom: 12rpx solid currentColor;
  1953. margin-left: 8rpx;
  1954. display: inline-block;
  1955. }
  1956. .arrow-up {
  1957. width: 0;
  1958. height: 0;
  1959. border-left: 10rpx solid transparent;
  1960. border-right: 10rpx solid transparent;
  1961. border-top: 12rpx solid currentColor;
  1962. margin-left: 8rpx;
  1963. display: inline-block;
  1964. }
  1965. .arrow-left {
  1966. width: 0;
  1967. height: 0;
  1968. border-top: 10rpx solid transparent;
  1969. border-bottom: 10rpx solid transparent;
  1970. border-right: 12rpx solid currentColor;
  1971. margin-left: 8rpx;
  1972. display: inline-block;
  1973. }
  1974. .arrow-right {
  1975. width: 0;
  1976. height: 0;
  1977. border-top: 10rpx solid transparent;
  1978. border-bottom: 10rpx solid transparent;
  1979. border-left: 12rpx solid currentColor;
  1980. margin-left: 8rpx;
  1981. display: inline-block;
  1982. }
  1983. .moreTabsContainer {
  1984. width: 100%;
  1985. display: flex;
  1986. gap: 5rpx;
  1987. margin-bottom: 5rpx;
  1988. }
  1989. .moreTabItem {
  1990. flex: 1;
  1991. padding: 10rpx 20rpx;
  1992. border-radius: 10rpx;
  1993. background-color: white;
  1994. font-size: 0.85rem;
  1995. color: #6a6a6a;
  1996. text-align: center;
  1997. }
  1998. .tab-selected {
  1999. color: #db1f1d;
  2000. }
  2001. .tab-selected::after {
  2002. content: "";
  2003. position: absolute;
  2004. bottom: 10rpx;
  2005. left: 50%;
  2006. transform: translateX(-50%);
  2007. width: 40rpx;
  2008. height: 5rpx;
  2009. background-color: #db1f1d;
  2010. }
  2011. .tab-setting-img {
  2012. width: 30rpx;
  2013. height: 30rpx;
  2014. }
  2015. .time-chart-container {
  2016. width: 100%;
  2017. min-height: 400rpx;
  2018. background-color: #ffffff;
  2019. box-sizing: border-box;
  2020. }
  2021. .stock-chart {
  2022. display: flex;
  2023. flex-direction: column;
  2024. align-items: center;
  2025. }
  2026. .kline-chart-container {
  2027. width: 100%;
  2028. height: 400rpx;
  2029. background-color: #ffffff;
  2030. padding: 20rpx;
  2031. box-sizing: border-box;
  2032. display: flex;
  2033. justify-content: center;
  2034. align-items: center;
  2035. }
  2036. .bottomTool {
  2037. width: 100%;
  2038. height: 150rpx;
  2039. position: fixed;
  2040. bottom: 0;
  2041. background-color: white;
  2042. display: flex;
  2043. box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
  2044. }
  2045. .index,
  2046. .function,
  2047. .favorites {
  2048. flex: 1;
  2049. display: flex;
  2050. justify-content: center;
  2051. align-items: center;
  2052. flex-direction: column;
  2053. font-size: 12px;
  2054. transition: all 0.2s ease;
  2055. }
  2056. .index:active,
  2057. .function:active,
  2058. .favorites:active {
  2059. background-color: rgba(99, 99, 99, 0.5);
  2060. transform: scale(0.95);
  2061. transition: all 0.1s ease;
  2062. }
  2063. .icon {
  2064. width: 40rpx;
  2065. height: 40rpx;
  2066. }
  2067. </style>