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.

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