|
|
<!-- @format -->
<template> <!-- 容器 --> <view class="container"> <!-- 标题 --> <view class="title-container"> <view class="title"> <image class="back-homepage-btn" src="/static/marketSituation-image/back.png" mode="返回按钮" @click="backToHomepage()"> </image> <view class="mid-title"> <view class="arrow-left"> </view> <image class="left-page" src="/static/img/left-page.png" mode="向左翻页"></image> <view class="stock-id"> <view class="stock-name"> {{ stockInformation.stockName }} </view> <view class="stock-code"> {{ stockInformation.stockCode }} </view> </view> <view class="arrow-right"> </view> <image class="right-page" src="/static/img/right-page.png" mode="向右翻页"></image> </view> <image class="search" src="/static/marketSituation-image/search.png" mode="搜索" @click="startTcp()"> </image> <view class="more" @click="disconnect()">···</view> </view> </view>
<view class="body"> <!-- 股票信息栏 --> <view class="stock-information" @click="openStockDetail()"> <view v-if="isStockDetail" class="stock-detail-container" @click.stop @click="closeStockDetail()"> <view class="stock-detail" @click.stop> <view class="first-column"> <view class="first-column-data" v-for="item in firstColumData" :key="item"> <view class="stock-detail-title"> {{ item.title }} </view> <view class="stock-detail-value"> {{ item.value }} </view> </view> </view> <view class="second-column"> <view class="second-column-data" v-for="item in secondColumnData" :key="item"> <view class="stock-detail-title"> {{ item.title }} </view> <view class="stock-detail-value"> {{ item.value }} </view> </view> </view> </view> </view> <view class="stock-current-data"> <view class="stock-current-price" :class="confirmStockColor(stockInformation.currentPrice, stockInformation.lastDayStockClosePrice)"> {{ Number(stockInformation.currentPrice).toFixed(2) }} </view> <view class="stock-current-other"> <view class="stock-current-value" :class="confirmStockColor(stockInformation.currentValue)"> {{ Number(stockInformation.currentValue).toFixed(2) }} </view> <view class="stock-current-ratio" :class="confirmStockColor(stockInformation.currentRatio)"> {{ Number(stockInformation.currentRatio).toFixed(2) }}% </view> </view> </view> <view class="stock-other-data"> <view class="first-line"> <view class="high-price"> 高 <view class="value" :class="confirmStockColor(stockInformation.highPrice, stockInformation.lastDayStockClosePrice)"> {{ Number(stockInformation.highPrice).toFixed(2) }} </view> </view>
<view class="volume"> 量 <view class="value"> {{ utils.formatStockNumber(stockInformation.volume, 2) }} </view> </view> <view class="volume-ratio"> 量比 <view class="value"> {{ Number(stockInformation.volumeRatio).toFixed(2) }} </view> </view> </view> <view class="second-line"> <view class="low-price"> 低 <view class="value" :class="confirmStockColor(stockInformation.lowPrice, stockInformation.lastDayStockClosePrice)"> {{ Number(stockInformation.lowPrice).toFixed(2) }} </view> </view> <view class="amount"> 额 <view class="value"> {{ utils.formatStockNumber(stockInformation.amount, 2) }} </view> </view> <view class="market-earn"> 市盈 <view class="value"> {{ Number(stockInformation.marketEarn).toFixed(2) }} </view> </view> </view> <view class="third-line"> <view class="open-price"> 开 <view class="value" :class="confirmStockColor(stockInformation.openPrice, stockInformation.lastDayStockClosePrice)"> {{ Number(stockInformation.openPrice).toFixed(2) }} </view> </view> <view class="turnover-ratio"> 换 <view class="value"> {{ Number(stockInformation.turnoverRatio).toFixed(2) }}% </view> </view> <view class="market-value"> 市值 <view class="value"> {{ utils.formatStockNumber(stockInformation.marketValue, 2) }} </view> </view> </view> </view> </view> <!-- 股票图表 --> <view class="stock-chart"> <view class="stock-kline-tab"> <!-- 1:分时 2:日K 3:周K 4:月K --> <view class="tab-time" :class="{ 'tab-selected': klineTab == 1 }" @click="selectKlineTab(1)"> 分时 </view> <view class="tab-day" :class="{ 'tab-selected': klineTab == 2 }" @click="selectKlineTab(2)"> 日K </view> <view class="tab-week" :class="{ 'tab-selected': klineTab == 3 }" @click="selectKlineTab(3)"> 周K </view> <view class="tab-month" :class="{ 'tab-selected': klineTab == 4 }" @click="selectKlineTab(4)"> 月K </view> <view class="tab-more" :class="{ 'tab-selected': klineTab != 1 && klineTab != 2 && klineTab != 3 && klineTab != 4 }" @click="isMoreTabs ? closeMoreTabs() : openMoreTabs()"> <view class="more-no-choose" v-if="klineTab == 1 || klineTab == 2 || klineTab == 3 || klineTab == 4"> 更多 </view> <view class="more-choose" v-else> {{ chooseTabName(klineTab) }} </view> <view :class="[isMoreTabs ? 'arrow-down' : 'arrow-up']"> </view> </view> <view class="tab-setting"> <image class="tab-setting-img" src="/static/marketSituation-image/marketCondition-image/setting2.png" mode="设置"></image> </view> </view> <view v-if="isMoreTabs" class="moreTabsContainer"> <view v-for="item in moreTabsData" :key="item" class="moreTabItem" :class="{ 'tab-selected': klineTab == item.value }" @click="selectMoreTab(item.value)"> {{ item.title }} </view> </view> <view class="stock-kline"> <view v-if="klineTab === 1 || klineTab === 2" class="time-chart-container" style="position: relative"> <!-- 主图Canvas --> <canvas canvas-id="stockChart" class="stock-chart" :width="canvasWidth" :height="canvasHeight" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px', pointerEvents: 'none', }" will-read-frequently="true" ></canvas> <!-- 动态数据Canvas层 --> <canvas canvas-id="dynamicCanvas" id="dynamicCanvas" :width="canvasWidth" :height="canvasHeight" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px', pointerEvents: 'none', position: 'absolute', top: '0', left: '0', }" will-read-frequently="true" ></canvas> <!-- 十字准心Canvas层 --> <canvas canvas-id="crosshairCanvas" id="crosshairCanvas" :width="canvasWidth" :height="canvasHeight" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px', position: 'absolute', top: '0', left: '0', }" will-read-frequently="true" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" ></canvas> </view> <!-- K线图区域 --> <view class="test" v-else-if="klineTab === 3"> <button @click="sendGetTimeData()">接收消息</button> <button @click="sendStopTimeData()">停止消息</button> </view> <view v-else class="kline-chart-container"> <text>K线图开发中...</text> </view> </view> </view> </view>
<view class="bottomTool"> <view class="index"> <image class="icon" src="/static/marketSituation-image/marketCondition-image/index.png" mode="指标仓库图标"> </image> 指标仓库 </view> <view class="function"> <image class="icon" src="/static/marketSituation-image/marketCondition-image/function.png" mode="功能图标"> </image> 功能 </view> <view class="favorites"> <image class="icon" src="/static/marketSituation-image/marketCondition-image/favorites.png" mode="加自选图标"></image> 加自选 </view> </view> </view></template>
<script setup>import { ref, reactive, computed, onMounted, watch, nextTick, onUnmounted, getCurrentInstance } from "vue";const instance = getCurrentInstance();import { prevClosePrice, timeData as testTimeData, klineData as testKlineData } from "@/common/stockTimeInformation.js";import { throttle } from "@/common/util.js";import { HCharts } from "@/common/canvasMethod.js";// const TCPSocket = uni.requireNativePlugin("Aimer-TCPPlugin");
const tcpObject = { ip: "192.168.1.9", port: 8080, reconnectInterval: 3000, isConnected: false,};const TIME_OUT = 1000 * 5;const resultR = ref([]);
// 股票信息栏变量
const stockInformation = ref({ stockName: "----", //股票名称
stockCode: "------", //股票代码
lastDayStockClosePrice: 0.0, //前一日收盘价
currentPrice: 0.0, //当前股价
currentValue: 0.0, //涨跌额度
currentRatio: 0.0, //涨跌幅度
highPrice: 0.0, //最高价
lowPrice: 0.0, //最低价
openPrice: 0.0, //开盘价
closePrice: 0.0, //收盘价
volume: 0.0, //成交量
volumeRatio: 0.0, //成交量比
amount: 0.0, //成交额
marketEarn: 0.0, //市盈
turnoverRatio: 0.0, //换手率
marketValue: 0.0, //市值
});
// 是否展开股票信息细节栏的判断变量
const isStockDetail = ref(false);
// 股票信息细节内容变量
const firstColumData = [ { title: "振幅", value: 0, }, { title: "现手", value: 0, }, { title: "内盘", value: 0, }, { title: "外盘", value: 0, }, { title: "流通股", value: 0, }, { title: "每股净资产", value: 0, }, { title: "净资产收益率", value: 0, }, { title: "总股本", value: 0, }, { title: "总资本", value: 0, }, { title: "总市值", value: 0, }, { title: "一个月最高", value: 0, }, { title: "一个月最低", value: 0, },];const secondColumnData = [ { title: "均价", value: 0, }, { title: "昨收", value: 0, }, { title: "委比", value: 0, }, { title: "委买", value: 0, }, { title: "委卖", value: 0, }, { title: "市盈利(静)", value: 0, }, { title: "市盈利(动)", value: 0, }, { title: "市净率", value: 0, }, { title: "涨停价", value: 0, }, { title: "跌停价", value: 0, }, { title: "一年最高", value: 0, }, { title: "一年最低", value: 0, },];
// 是否展开更多Tab的判断变量
const isMoreTabs = ref(false);
const moreTabsData = ref([ { title: "1分", value: 5, }, { title: "5分", value: 6, }, { title: "15分", value: 7, }, { title: "30分", value: 8, }, { title: "60分", value: 9, }, { title: "季K", value: 10, }, { title: "年K", value: 11, },]);
// 股票当前选中的K线类型Tab
// 1:分时 2:日K 3:周K 4:月K
const klineTab = ref(2);
const sendGetTimeData = () => { console.log("执行发送消息的方法"); try { TCPSocket.send({ channel: "1", message: '{"command": "real_time", "stock_code": "SH.000001"}', }); } catch (e) { console.log("error", e); }};
const sendStopTimeData = () => { try { TCPSocket.send({ channel: "1", message: '{"command": "stop_real_time"}', }); } catch (e) { console.log("error", e); }};
// 确定股票数据的颜色方法
const confirmStockColor = (price, lastDayStockClosePrice) => { if (typeof lastDayStockClosePrice === "undefined") { if (price == 0) { return "price-none"; } else if (price > 0) { return "price-up"; } else { return "price-down"; } } else { if (price == lastDayStockClosePrice) { return "price-none"; } else if (price > lastDayStockClosePrice) { return "price-up"; } else { return "price-down"; } }};
// 股票K线类型方法
const selectKlineTab = (tabId) => { klineTab.value = tabId; if (tabId === 1) { stockInformation.value.lastDayStockClosePrice = prevClosePrice; } initCanvas(); // startAddDataTimer();
};// 返回按钮
const backToHomepage = () => { const pages = getCurrentPages(); if (pages.length > 1) { uni.navigateBack(); } else { // 如果没有上一页,跳转到首页
uni.reLaunch({ url: "/pages/home/home", }); }};
const openStockDetail = () => { isStockDetail.value = true;};
const closeStockDetail = () => { isStockDetail.value = false;};
const openMoreTabs = () => { isMoreTabs.value = true;};
const closeMoreTabs = () => { isMoreTabs.value = false;};
const selectMoreTab = (value) => { selectKlineTab(value);};
const chooseTabName = () => { if (klineTab.value === 5) { return "1分"; } else if (klineTab.value === 6) { return "5分"; } else if (klineTab.value === 7) { return "15分"; } else if (klineTab.value === 8) { return "30分"; } else if (klineTab.value === 9) { return "60分"; } else if (klineTab.value === 10) { return "季K"; } else if (klineTab.value === 11) { return "年K"; }};
// 画布对象
const canvasWidth = ref(100);const canvasHeight = ref(100);const CANVAS_BACKGROUND_COLOR = "#fff";const TEXT_COLOR1 = "#000";const TEXT_COLOR2 = "red";const LINE_COLOR = "#e5e5e5";const timeChartObject = ref({ min: 0, max: 0,});const klineChartObject = ref({ min: 0, max: 0,});
const chartRange = ref();
// 开盘时间
const openTime = "09:30";// 收盘时间
const closeTime = "15:00";
const ctx = ref(null);const dynamicCtx = ref(null); // 动态Canvas上下文
const crosshairCtx = ref(null); // 十字准心Canvas上下文
const pixelRatio = ref(1);// 触屏对象
const touchState = reactive({ startX: 0, startY: 0, isMoving: false, moveDistance: 0, moveThreshold: 10, //移动阈值(用于判断是否在点击)
scale: 1, // 当前缩放比例
minScale: 0.3, // 最小缩放比例
maxScale: 5, // 最大缩放比例
baseVisibleCount: 40, // 基准可见K线数量
offset: 0, // 数据偏移量
isZooming: false, // 是否正在缩放
initialDistance: 0, // 初始两指距离
initialScale: 1, // 初始缩放比例
});// 计算当前可见的K线数量
const visibleCount = computed(() => { return Math.floor(touchState.baseVisibleCount / touchState.scale);});// 计算当前显示的数据范围
const visibleDataRange = computed(() => { const start = Math.max(0, klineData.value.length - visibleCount.value - touchState.offset); const end = Math.min(klineData.value.length, start + visibleCount.value); return { start, end, };});// 获取当前可见的数据
const visibleKlineData = computed(() => { const { start, end } = visibleDataRange.value; return klineData.value.slice(start, end);});// 计算两点之间的距离
const getDistance = (touch1, touch2) => { const dx = touch1.x - touch2.x; const dy = touch1.y - touch2.y; return Math.sqrt(dx * dx + dy * dy);};
// 十字准线相关状态
const crosshair = reactive({ show: false, x: 0, y: 0, currentData: null, snapToData: true,});// 绘制网格和坐标轴
const grid = [ { top: 20, bottom: canvasHeight.value * 0.5, left: 5, right: 5, lineColor: LINE_COLOR, lineWidth: 1, horizontalLineNum: 5, verticalLineNum: 5, }, { top: canvasHeight.value * 0.5 + 20, bottom: 20, left: 5, right: 5, lineColor: LINE_COLOR, lineWidth: 1, horizontalLineNum: 3, verticalLineNum: 5, },];
// 绘制网格和坐标轴
const dayGrid = [ { top: 20, bottom: canvasHeight.value * 0.5, left: 5, right: 5, lineColor: LINE_COLOR, lineWidth: 1, horizontalLineNum: 5, verticalLineNum: 2, }, { top: canvasHeight.value * 0.5 + 20, bottom: 20, left: 5, right: 5, lineColor: LINE_COLOR, lineWidth: 1, horizontalLineNum: 3, verticalLineNum: 2, }, { top: canvasHeight.value * 0.5 + 20, bottom: 20, left: 5, right: 5, lineColor: LINE_COLOR, lineWidth: 1, horizontalLineNum: 3, verticalLineNum: 2, },];// 工具函数
const utils = { // 格式化价格
formatPrice(price) { return price.toFixed(2); },
// 计算数据范围
calculateDataRange(data, key) { if (!data || data.length === 0) { return { min: 0, max: 0, }; } const values = data.map((item) => item[key]); return { min: Math.min(...values), max: Math.max(...values), }; }, // 计算标签
calculateLabel(data, type = 2, preClosePrice = 0, key, num) { let label = []; if (key === "price") { // 分时价格区间
if (type == 1) { const priceRange = utils.calculateDataRange(data, "price"); const theMost = Math.max(priceRange.max - preClosePrice, preClosePrice - priceRange.min); const mid = (num - 1) / 2; // 计算分时价格标签
label[mid] = { value: utils.formatPrice(preClosePrice), ratio: utils.formatPrice(0) + "%", }; for (let i = 0; i < mid; i++) { label[i] = { value: utils.formatPrice(preClosePrice + (theMost * (mid - i)) / mid), ratio: utils.formatPrice((100 * (theMost * (mid - i))) / mid / preClosePrice) + "%", };
label[num - 1 - i] = { value: utils.formatPrice(preClosePrice - (theMost * (mid - i)) / mid), ratio: utils.formatPrice((-1 * 100 * (theMost * (mid - i))) / mid / preClosePrice) + "%", }; } chartRange.value.push({ max: preClosePrice + theMost, min: preClosePrice - theMost, }); // timeChartObject.value.max = preClosePrice + theMost;
// timeChartObject.value.min = preClosePrice - theMost;
return label; } else { const highPriceRange = utils.calculateDataRange(data, "high"); const lowPriceRange = utils.calculateDataRange(data, "low"); const priceRange = { max: highPriceRange.max * 1.01, min: lowPriceRange.min * 0.99, }; const priceDiff = priceRange.max - priceRange.min; for (let i = 0; i < num; ++i) { label[i] = { value: utils.formatPrice(priceRange.max - (i * priceDiff) / (num - 1)), }; } chartRange.value.push(priceRange); // klineChartObject.value.max = highPriceRange.max * 1.01;
// klineChartObject.value.min = lowPriceRange.min * 0.99;
return label; } } else if (key === "volume") { const volumeRange = utils.calculateDataRange(data, "volume"); chartRange.value.push({ max: volumeRange.max, min: 0, }); label[0] = { value: utils.formatPrice(volumeRange.max), }; label[1] = { value: utils.formatPrice(0), }; return label; } return null; }, // 线性插值
lerp(start, end, factor) { return start + (end - start) * factor; },
// 根据X坐标找到最近的数据点
findNearestDataPoint(x, pointLen, data, grid, offset) { if (!data.length) return null; const width = canvasWidth.value; const height = canvasHeight.value; // 计算每个数据点的X坐标间隔
// 倒推 const x=5+(index*(width-10)/pointLen) 已知x求index
const xStep = width - grid[0].left - grid[0].right;
// 计算触摸点对应的数据索引
const touchX = (x - grid[0].left - offset) * pointLen; let nearestIndex = Math.round(touchX / xStep); let dataX; // 确保索引在有效范围内
if (nearestIndex >= 0 && nearestIndex <= data.length - 1) { dataX = offset + grid[0].left + (nearestIndex * (width - grid[0].left - grid[0].right)) / pointLen; } else { dataX = x; } nearestIndex = Math.max(0, Math.min(nearestIndex, data.length - 1));
return { ...data[nearestIndex], index: nearestIndex, x: dataX, }; },
// 根据Y坐标计算价格
calculatePriceFromY(y, data, grid) { if (!data.length) return 0;
const width = canvasWidth.value; const height = canvasHeight.value; // 上下边距1
const topPadding1 = 20; const bottomPadding1 = height * 0.4; // 上下边距2
const topPadding2 = height - bottomPadding1 + 40; const bottomPadding2 = 5; // 左右边距
const verticalPadding = 5;
let chartY; let price;
for (let i = 0; i < grid.length; i++) { if (y >= grid[i].top && y <= height - grid[i].bottom) { const chartDiff = chartRange.value[i].max - chartRange.value[i].min; chartY = y - grid[i].top; price = chartRange.value[i].max - (chartY / (height - grid[i].bottom - grid[i].top)) * chartDiff; break; } }
// if (y >= topPadding1 && y <= height - bottomPadding1) {
// const priceDiff = priceRange.max - priceRange.min;
// chartY = y - topPadding1;
// price = priceRange.max - (chartY / (height - topPadding1 - bottomPadding1)) * priceDiff;
// } else if (y >= topPadding2 && y <= height - bottomPadding2) {
// const volumeRange = utils.calculateDataRange(data, "volume");
// const volumeDiff = volumeRange.max - 0;
// chartY = y - topPadding2;
// price = volumeRange.max - (chartY / (height - topPadding2 - bottomPadding2)) * volumeDiff;
// }
return price; }, // 股市数值格式化方法
formatStockNumber(value, decimalPlaces = 2) { const num = Number(value); if (isNaN(num)) return "0";
const absNum = Math.abs(num); const sign = num < 0 ? "-" : "";
if (absNum >= 1000000000000) { // 万亿级别
return sign + (absNum / 1000000000000).toFixed(decimalPlaces) + "万亿"; } else if (absNum >= 100000000) { // 亿级别
return sign + (absNum / 100000000).toFixed(decimalPlaces) + "亿"; } else if (absNum >= 10000) { // 万级别
return sign + (absNum / 10000).toFixed(decimalPlaces) + "万"; } else { // 小于万的直接显示
return sign + absNum.toFixed(decimalPlaces); } },};
let text = [ [ { name: "领先", value: "暂无数据", color: "red", }, { name: "价", value: utils.formatPrice(stockInformation.value.currentPrice), color: "black", }, ], [ { name: "量", value: utils.formatStockNumber(stockInformation.value.volume), color: "green", }, { name: "额", value: "暂无数据", color: "black", }, ],];// 示例数据
const timeData = ref([]);
const klineData = ref([]);
const initCanvas = async () => { try { crosshair.show = false;
grid[0].bottom = canvasHeight.value * 0.4; grid[1].top = canvasHeight.value * 0.6 + 30;
dayGrid[0].top = 20; dayGrid[0].bottom = canvasHeight.value * 0.6; dayGrid[1].top = canvasHeight.value * 0.4 + 30; dayGrid[1].bottom = canvasHeight.value * 0.3; dayGrid[2].top = canvasHeight.value * 0.7 + 20; dayGrid[2].bottom = 20;
// 等待DOM更新
await nextTick(); ctx.value = uni.createCanvasContext("stockChart", instance.proxy); // 初始化动态数据Canvas上下文
dynamicCtx.value = uni.createCanvasContext("dynamicCanvas", instance.proxy); // 初始化十字准心Canvas上下文
crosshairCtx.value = uni.createCanvasContext("crosshairCanvas", instance.proxy);
if (ctx.value) { // 设置Canvas的内部绘图区域尺寸
ctx.value.canvas = { width: canvasWidth.value, height: canvasHeight.value, }; } else { console.warn("Canvas上下文未初始化,跳过绘制"); } // 确保Canvas上下文知道正确的尺寸
if (dynamicCtx.value) { // 设置Canvas的内部绘图区域尺寸
dynamicCtx.value.canvas = { width: canvasWidth.value, height: canvasHeight.value, }; } else { console.warn("动态Canvas上下文未初始化,跳过绘制"); } if (crosshairCtx.value) { // 设置Canvas的内部绘图区域尺寸
crosshairCtx.value.canvas = { width: canvasWidth.value, height: canvasHeight.value, }; } else { console.warn("十字准心Canvas上下文未初始化,跳过绘制"); } // 等待DOM更新
await nextTick(); console.log("ctx", ctx.value); drawChart(); } catch (error) { console.error("初始化Canvas失败:", error); }};
// 绘制图表主函数-设置基础数据
const drawChart = () => { if (!ctx.value || !dynamicCtx.value || !crosshairCtx.value) { console.warn("Canvas上下文未初始化,跳过绘制"); return; } const data = klineTab.value == 1 ? timeData.value : klineData.value; chartRange.value = []; // 清除画布
// HCharts.setCanvasColor(ctx.value, width, height, CANVAS_BACKGROUND_COLOR);
// HCharts.setCanvasColor(dynamicCtx.value, width, height, CANVAS_BACKGROUND_COLOR);
// HCharts.setCanvasColor(crosshairCtx.value, width, height, CANVAS_BACKGROUND_COLOR);
// 根据当前标签绘制对应图表
if (klineTab.value == 1) { // 设置标签样式
const label = [ { text: utils.calculateLabel(data, 1, stockInformation.value.lastDayStockClosePrice, "price", grid[0].horizontalLineNum), color: [TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR2], fontSize: 12, lineStyle: ["solid", "solid", "solid", "solid", "solid"], onlyTwo: false, }, { text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "volume", grid[1].horizontalLineNum), color: [TEXT_COLOR1, TEXT_COLOR1], lineStyle: ["solid", "solid", "solid"], fontSize: 12, onlyTwo: true, }, ]; // 把label加进grid中
grid[0].label = label[0]; grid[1].label = label[1];
drawTimeChart(); } else { // 设置标签样式
const label = [ { text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "price", grid[0].horizontalLineNum), color: [TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR1, TEXT_COLOR2], lineStyle: ["solid", "dash", "dash", "dash", "solid"], fontSize: 12, onlyTwo: false, }, { text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "volume", grid[1].horizontalLineNum), color: [TEXT_COLOR1, TEXT_COLOR1], lineStyle: ["solid", "dash", "solid"], fontSize: 12, onlyTwo: true, }, { text: utils.calculateLabel(data, 2, stockInformation.value.lastDayStockClosePrice, "volume", grid[1].horizontalLineNum), color: [TEXT_COLOR1, TEXT_COLOR1], lineStyle: ["solid", "dash", "solid"], fontSize: 12, onlyTwo: true, }, ]; // 把label加进grid中
dayGrid[0].label = label[0]; dayGrid[1].label = label[1]; dayGrid[2].label = label[2]; // HCharts.drawGrid(ctx.value, canvasWidth.value, canvasHeight.value, dayGrid, data[0].date, data[data.length - 1].date);
drawKLineChart(); }
// crosshairCtx.value.draw();
};const throttledDrawChart = throttle(drawChart, 50);
// // 绘制分时图
const drawTimeChart = () => { drawCtxChart(grid); drawDynamicCtxChart();};
// // 绘制K线图
const drawKLineChart = () => { drawCtxChart(dayGrid); drawDynamicCtxChart();};
const drawCtxChart = (paramGrid) => { // 检查Canvas上下文是否已初始化
if (!ctx.value) { console.warn("Canvas上下文未初始化,跳过绘制"); return; } // 绘制网格
HCharts.drawGrid(ctx.value, canvasWidth.value, canvasHeight.value, paramGrid, openTime, closeTime); //执行绘制
ctx.value.draw();};const drawDynamicCtxChart = () => { if (klineTab.value == 1) { //绘制价格曲线
HCharts.drawPriceLine(dynamicCtx.value, canvasWidth.value, canvasHeight.value, timeData.value, grid, chartRange.value[0]); //绘制成交量
HCharts.drawVolume( dynamicCtx.value, canvasWidth.value, canvasHeight.value, timeData.value, 2, 240, grid, { max: utils.calculateDataRange(timeData.value, "volume").max, min: 0, }, 0 ); HCharts.drawAxisLabels(dynamicCtx.value, canvasWidth.value, canvasHeight.value, grid); HCharts.drawTopPriceDisplay(crosshairCtx.value, grid, text); } else { drawKLine(dynamicCtx.value); //绘制成交量
HCharts.drawVolume( dynamicCtx.value, canvasWidth.value, canvasHeight.value, klineData.value, 2, klineData.value.length, dayGrid, { max: utils.calculateDataRange(klineData.value, "volume").max, min: 0, }, touchState.offset ); //绘制成交量
HCharts.drawVolume( dynamicCtx.value, canvasWidth.value, canvasHeight.value, klineData.value, 3, klineData.value.length, dayGrid, { max: utils.calculateDataRange(klineData.value, "volume").max, min: 0, }, touchState.offset ); HCharts.drawAxisLabels(dynamicCtx.value, canvasWidth.value, canvasHeight.value, dayGrid); // drawMovingAverage();
HCharts.drawTopPriceDisplay(crosshairCtx.value, dayGrid, []); } //执行绘制
dynamicCtx.value.draw(false); crosshairCtx.value.draw();};
const throttledDrawDynamicCtxChart = throttle(drawDynamicCtxChart, 50);// const throttledDrawKLineChart = throttle(drawKLineChart, 10);
// 绘制K线
const drawKLine = (ctx) => { const data = klineData.value; if (!data.length) return;
const width = canvasWidth.value; const height = canvasHeight.value;
// 计算价格范围
const priceRange = chartRange.value[0]; const priceDiff = priceRange.max - priceRange.min;
const areaWidth = (width - dayGrid[0].left - dayGrid[0].right) / data.length; const candleWidth = areaWidth * 0.6;
data.forEach((item, index) => { const x = touchState.offset + dayGrid[0].left + (index * (width - dayGrid[0].left - dayGrid[0].right)) / data.length;
// 计算坐标
const highY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.high - priceRange.min) / priceDiff); const lowY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.low - priceRange.min) / priceDiff); const openY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.open - priceRange.min) / priceDiff); const closeY = dayGrid[0].top + (height - dayGrid[0].top - dayGrid[0].bottom) * (1 - (item.close - priceRange.min) / priceDiff);
// 判断涨跌
const isRise = item.close >= item.open; const color = isRise ? "green" : "red";
// 绘制上下影线
ctx.setStrokeStyle(color); ctx.setLineWidth(1); ctx.beginPath(); ctx.moveTo(x, highY); ctx.lineTo(x, lowY); ctx.stroke();
// 绘制K线实体
const entityTop = isRise ? closeY : openY; const entityBottom = isRise ? openY : closeY; const entityHeight = Math.max(Math.abs(entityBottom - entityTop), 1);
ctx.setFillStyle(color); ctx.fillRect(x - candleWidth / 2, entityTop, candleWidth, entityHeight); });};
// 绘制移动平均线
const drawMovingAverage = () => { const data = klineData.value; if (!data.length) return;
const ma5 = calculateMA(data, 5); const ma10 = calculateMA(data, 10);
drawMALine(ma5, "#fadb14", "MA5"); // 黄色
drawMALine(ma10, "#eb2f96", "MA10"); // 粉色
};
// 计算移动平均线
const calculateMA = (data, period) => { const result = []; for (let i = period - 1; i < data.length; i++) { let sum = 0; for (let j = 0; j < period; j++) { sum += data[i - j].close; } result.push({ index: i, value: sum / period, }); } return result;};
// 绘制均线
const drawMALine = (maData, color, label) => { if (!maData.length) return;
const width = canvasWidth.value; const height = canvasHeight.value; const data = klineData.value;
// 计算价格范围
const highs = data.map((item) => item.high); const lows = data.map((item) => item.low); const priceRange = chartRange.value[0]; const priceDiff = priceRange.max - priceRange.min;
ctx.value.setStrokeStyle(color); ctx.value.setLineWidth(1.5); ctx.value.beginPath();
maData.forEach((point, idx) => { const x = 40 + (point.index * (width - 60)) / (data.length - 1); const y = 20 + (height - 60) * (1 - (point.value - priceRange.min) / priceDiff);
if (idx === 0) { ctx.value.moveTo(x, y); } else { ctx.value.lineTo(x, y); } });
ctx.value.stroke();
// 绘制图例
if (maData.length > 0) { const lastPoint = maData[maData.length - 1]; const x = 40 + (lastPoint.index * (width - 60)) / (data.length - 1); const y = 20 + (height - 60) * (1 - (lastPoint.value - priceRange.min) / priceDiff);
HCharts.drawText(ctx.value`${label}: ${utils.formatPrice(lastPoint.value)}`, x + 10, y - 5, 12, color); }};// 触摸事件处理
const touchStart = (e) => { e.preventDefault(); // 阻止页面滚动等默认行为
if (typeof e.touches[1] === "undefined") { touchState.startX = e.touches[0].x; touchState.startY = e.touches[0].y; touchState.isMoving = false; } else if (typeof e.touches[1] !== "undefined" && !crosshair.show) { // touchState.startX1 = e.touches[0].x;
// touchState.startY1 = e.touches[0].y;
// touchState.startX2 = e.touches[1].x;
// touchState.startY2 = e.touches[1].y;
// touchState.isMoving = false;
}};
const touchMove = (e) => { touchState.isMoving = true; // 计算移动距离
const currentX = e.touches[0].x; const currentY = e.touches[0].y; const deltaX = currentX - touchState.startX; const deltaY = currentY - touchState.startY; touchState.moveDistance = Math.max(touchState.moveDistance, Math.sqrt(deltaX * deltaX + deltaY * deltaY)); if (crosshair.show) { if (isInChartArea(currentX, currentY, klineTab.value === 1 ? grid : dayGrid)) { throttledUpdateCrosshair(currentX, currentY); } else { // 如果移出图表区域,隐藏十字准线
// crosshair.show = false;
if (klineTab.value === 1) { HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, grid, crosshair, text); } else { HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, dayGrid, crosshair, []); } } } else { if (klineTab.value === 2) { // if(currentY)
if (currentX < touchState.startX) { touchState.offset += currentX - touchState.startX; throttledDrawDynamicCtxChart(); touchState.startX = currentX; } else { touchState.offset += currentX - touchState.startX; throttledDrawDynamicCtxChart(); touchState.startX = currentX; } if (canvasWidth.value + touchState.offset < 30) { touchState.offset = 0; } } }};
const touchEnd = (e) => { // 触摸结束,隐藏十字准线
if (touchState.moveDistance < touchState.moveThreshold) { // 移动距离小于阈值,认为是点击事件
crosshair.show = !crosshair.show; }
if (crosshair.show) { const currentX = e.changedTouches[0].x; const currentY = e.changedTouches[0].y; if (isInChartArea(currentX, currentY, klineTab.value === 1 ? grid : dayGrid)) { throttledUpdateCrosshair(currentX, currentY); } } if (klineTab.value === 1) { HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, grid, crosshair, text); } else { HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, dayGrid, crosshair, []); } // 重置移动距离
touchState.moveDistance = 0;};
// 检查坐标是否在图表区域内
const isInChartArea = (x, y, grid) => { const width = canvasWidth.value; const height = canvasHeight.value;
if (x < grid[0].left || x > width - grid[0].right) return false;
for (let i = 0; i < grid.length; i++) { if (y >= grid[i].top && y <= height - grid[i].bottom) return true; } return false;};
// 更新十字准线位置和数据
const updateCrosshair = (x, y) => { if (!crosshair.show) return; // 更新Y坐标以匹配实际价格
const data = klineTab.value === 1 ? timeData.value : klineData.value;
const width = canvasWidth.value; const height = canvasHeight.value;
crosshair.x = x; crosshair.y = y; const nearestData = utils.findNearestDataPoint(x, klineTab.value === 1 ? 240 : data.length, data, klineTab.value === 1 ? grid : dayGrid, klineTab.value === 1 ? 0 : touchState.offset); crosshair.x = nearestData.x; stockInformation.value.currentPrice = nearestData.price || nearestData.close; stockInformation.value.volume = nearestData.volume; // 涨跌额度
if (klineTab.value == 1) { stockInformation.value.currentValue = stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice; stockInformation.value.currentRatio = ((stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice) / stockInformation.value.lastDayStockClosePrice) * 100; } else { stockInformation.value.openPrice = nearestData.open; stockInformation.value.highPrice = nearestData.high; stockInformation.value.lowPrice = nearestData.low; if (nearestData.index != 0) { stockInformation.value.lastDayStockClosePrice = data[nearestData.index - 1].close; stockInformation.value.currentValue = stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice; stockInformation.value.currentRatio = ((stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice) / stockInformation.value.lastDayStockClosePrice) * 100; } } if (klineTab.value === 1 && crosshair.snapToData) { // 吸附到最近的数据点
if (nearestData) { crosshair.currentData = nearestData; const priceDiff = chartRange.value[0].max - chartRange.value[0].min; crosshair.y = grid[0].top + (height - grid[0].bottom - grid[0].top) * (1 - (nearestData.price - chartRange.value[0].min) / priceDiff); } } else { // 自由移动模式
const currentPrice = utils.calculatePriceFromY(y, data, klineTab.value === 1 ? grid : dayGrid); crosshair.currentData = { ...nearestData, price: currentPrice, }; }
if (crosshair?.currentData?.volume) { text[0][1].value = crosshair.currentData.price; text[1][0].value = crosshair.currentData.volume; } // 只重绘十字准心,不重绘整个图表
if (klineTab.value === 1) { HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, grid, crosshair, text); } else { HCharts.drawCrosshair(crosshairCtx.value, canvasWidth.value, canvasHeight.value, dayGrid, crosshair, []); }};
const throttledUpdateCrosshair = throttle(updateCrosshair, 10);
// 缩放控制函数
const zoomIn = () => { touchState.scale = Math.min(touchState.maxScale, touchState.scale * 1.2); drawChart();};
const zoomOut = () => { touchState.scale = Math.max(touchState.minScale, touchState.scale / 1.2); drawChart();};
// 监听数据变化
// watch([timeData, klineData], () => {
// console.log("数据变化");
// drawChart();
// });
watch(klineTab, () => { console.log("标签页变化"); drawChart();});/* const connectTcp = (ip, port) => { console.log(`🚀 正在连接TCP服务器 ${ip}:${port}...`); TCPSocket.connect( { channel: "1", ip: ip, port: port, }, (result) => { console.log(result); if (result.status == 0) { tcpObject.isConnected = true; console.log("tcp连接成功"); } else if (result.status == 1) { tcpObject.isConnected = false; console.log("tcp断开连接"); } if (result.receivedMsg) { console.log("收到服务器发送的数据", result.receivedMsg); if (result.receivedMsg.length == 0 || result.receivedMsg.includes("欢迎")) { resultR.value = []; } else { const msgPacket = JSON.parse(result.receivedMsg); resultR.value.push(msgPacket); // 股票代码
if (msgPacket.stock_code) { stockInformation.value.stockCode = msgPacket.stock_code; } // 股票名称
if (msgPacket.stock_name) { stockInformation.value.stockName = msgPacket.stock_name; } // 前一日收盘价
if (msgPacket.pre_close) { stockInformation.value.lastDayStockClosePrice = msgPacket.pre_close; } // 当前股价(收盘价)
if (msgPacket.current_price) { stockInformation.value.currentPrice = msgPacket.current_price; } // 涨跌额度
if (msgPacket.current_price && msgPacket.pre_close) { stockInformation.value.currentValue = msgPacket.current_price - msgPacket.pre_close; stockInformation.value.currentRatio = ((msgPacket.current_price - msgPacket.pre_close) / msgPacket.pre_close) * 100; }
// 最高价
if (msgPacket.high_price) { stockInformation.value.highPrice = msgPacket.high_price; } // 最低价
if (msgPacket.close_price) { stockInformation.value.lowPrice = msgPacket.close_price; } // 开盘价
if (msgPacket.open_price) { stockInformation.value.openPrice = msgPacket.open_price; } // 开盘价
if (msgPacket.low_price) { stockInformation.value.lowPrice = msgPacket.low_price; } // 收盘价(当前股价)
if (msgPacket.current_price) { stockInformation.value.closePrice = msgPacket.current_price; } // 成交量
if (msgPacket.volume) { stockInformation.value.volume = msgPacket.volume; } // 成交量比
if (msgPacket.volume_ratio) { stockInformation.value.volumeRatio = msgPacket.volume_ratio; } // 成交额
if (msgPacket.amount) { stockInformation.value.amount = msgPacket.amount; } // 市盈
if (msgPacket.market_earn) { stockInformation.value.marketEarn = msgPacket.market_earn; } // 换手率
if (msgPacket.turnover_ratio) { stockInformation.value.turnoverRatio = msgPacket.turnover_ratio; } // 市值
if (msgPacket.total_market_value) { stockInformation.value.marketValue = msgPacket.total_market_value; } } } // if (result.receivedHexMsg) {
// //服务器返回16进制数据
// console.log("📥 收到16进制数据:", result.receivedHexMsg);
// let msg = result.receivedHexMsg;
// let sum = msg.length / 2;
// let arr = [];
// for (let k = 0; k < sum; k++) {
// let i = msg.substring(k * 2, k * 2 + 2);
// arr.push(i);
// }
// console.log("转换成16进制数组:", arr);
// if (tcpObject.responseCallback) {
// tcpObject.responseCallback({
// data: arr,
// });
// }
// }
// 设置连接超时
setTimeout(() => { if (!tcpObject.isConnected) { throw new Error("连接超时"); return; } }, TIME_OUT); } ); }; // 断开连接
const disconnect = () => { try { TCPSocket.disconnect({ channel: "1", }); tcpObject.isConnected = false; console.log("✅ 连接已关闭"); } catch (e) { console.log("⚠️ 断开连接时发生错误:", e.message); } };
const startTcp = async () => { console.log("🚀 TCP客户端启动"); console.log(`目标服务器: ${tcpObject.ip}:${tcpObject.port}`); try { // 连接服务器
await connectTcp(tcpObject.ip, tcpObject.port); } catch (e) { console.log("error", e); } }; */// 定时器标识(用于清除定时器)
let timer = null;let index = 0;// 定时添加数据的函数
const startAddDataTimer = () => { if (timer) { console.log("存在旧定时器,卸载旧定时器"); clearInterval(timer); } console.log("开始定时任务"); // 每隔5秒执行一次
timer = setInterval(() => { if (index < testTimeData.length) { timeData.value.push(testTimeData[index]); console.log("新增数据:", testTimeData[index]); // 触发图表重新绘制
drawChart(); index++; } else { clearInterval(timer); } }, 2000); // 5000毫秒 = 5秒
};
// 保存定时器,用于页面卸载时清理
onUnmounted(() => { // disconnect();
if (timer) { console.log("卸载定时器"); clearInterval(timer); }});
onMounted(async () => { // 获取系统信息,处理高清屏
const systemInfo = uni.getSystemInfoSync(); pixelRatio.value = systemInfo.pixelRatio; // 设置Canvas实际像素(考虑pixelRatio以获得高清效果)
// 1rpx = 设备屏幕宽度 / 750
const rpxToPx = systemInfo.windowWidth / 750; const offsetHeight = (150 + 200 + 80 + 150 + 30) * rpxToPx; // 350rpx转换为px
const calculatedHeight = systemInfo.windowHeight - offsetHeight; canvasWidth.value = systemInfo.windowWidth; canvasHeight.value = Math.max(calculatedHeight, canvasHeight.value); // startTcp();
// timeData.value = testTimeData;
timeData.value = testTimeData.slice(0, 100); klineData.value = testKlineData; // klineData.value = testKlineData.slice(-touchState.baseVisibleCount);
// 前一日收盘价
let prevClosePrice = timeData.value[timeData.value.length - 2].price || stockInformation.value.closePrice; stockInformation.value.lastDayStockClosePrice = prevClosePrice; // 当前股价
stockInformation.value.currentPrice = timeData.value[timeData.value.length - 1].price; // 涨跌额度
stockInformation.value.currentValue = stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice; // 涨跌幅度
stockInformation.value.currentRatio = ((stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice) / stockInformation.value.lastDayStockClosePrice) * 100; // 成交量
stockInformation.value.volume = timeData.value[timeData.value.length - 1].volume; text[0][1].value = utils.formatPrice(stockInformation.value.currentPrice); text[1][0].value = utils.formatStockNumber(stockInformation.value.volume);
await nextTick(); setTimeout(() => { initCanvas(); }, 200);});</script>
<style>.container { width: 100%; min-height: 100vh; background-color: #f6f6f6;}
.title-container { width: 100%; height: 150rpx; background-color: white; display: flex; flex-direction: column; justify-content: flex-end;}
.title { /* border: 1px solid #ff0000; */ position: relative; /* 关键:作为绝对定位的父容器 */ width: 100%; height: 80rpx; display: flex; justify-content: center; align-items: center;}
.back-homepage-btn { margin-left: 40rpx; margin-right: auto; height: 30rpx; width: 20rpx; z-index: 1;}
.mid-title { position: absolute; left: 0; right: 0; display: flex; justify-content: center; align-items: center;}
.left-page { height: 40rpx; width: 40rpx;}
.right-page { height: 40rpx; width: 40rpx;}
.stock-id { margin: 0rpx 40rpx; display: flex; flex-direction: column; text-align: center;}
.stock-name { font-weight: bold;}
.stock-code { font-size: 0.8rem; font-weight: bold; color: #a1a1a1;}
.search { height: 40rpx; width: 40rpx;}
.more { height: 100%; display: flex; justify-content: center; align-items: center; margin-right: 40rpx; margin-left: 20rpx;}
.body { overflow: auto; height: calc(100vh - 305rpx); /* border: 1px solid red; */}
.stock-information { background-color: white; width: 100%; height: 200rpx; margin: 10rpx 0rpx; display: flex; align-items: center; position: relative; /* 为伪元素定位做准备 */}
/* 右下角黑色三角形 */.stock-information::after { content: ""; position: absolute; bottom: 0; right: 0; width: 0; height: 0; border-left: 20rpx solid transparent; border-bottom: 20rpx solid #6a6a6a;}
.stock-detail-container { position: absolute; top: 100%; /* 在父容器下方 */ left: 0; /* 从左边开始 */ right: 0; /* 到右边结束 */ width: 100%; /* height: 300rpx; 使用固定高度替代calc,避免计算问题 */ height: calc(100vh - 515rpx); display: flex; flex-direction: column; background-color: rgba(0, 0, 0, 0.6); z-index: 100; box-sizing: border-box; pointer-events: auto;}
.stock-detail { border: 1px solid #cacaca; width: 100%; display: flex; background-color: white; padding: 10rpx 0;}
.first-column,.second-column { width: 50%; height: 100%; display: flex; flex-direction: column; color: #6a6a6a; font-size: 0.8rem;}
.first-column-data,.second-column-data { display: flex; justify-content: space-between; align-items: center; padding: 10rpx 40rpx;}
.stock-detail-title { color: #6a6a6a;}
.stock-detail-value { color: black;}
.price-up { color: #10b981;}
.price-down { color: #ef4444;}
.price-none { color: black;}
.stock-current-data { width: 30%; height: 70%; display: flex; flex-direction: column;}
.stock-current-price { font-weight: bold; font-size: 1.2rem; width: 100%; height: 50%; display: flex; justify-content: center; align-items: center;}
.stock-current-other { width: 100%; height: 50%; display: flex; font-weight: bold; font-size: 0.6rem;}
.stock-current-value { width: 50%; height: 100%; display: flex; justify-content: center; align-items: center;}
.stock-current-ratio { width: 50%; height: 100%; display: flex; justify-content: center; align-items: center;}
.stock-other-data { height: 100%; width: 70%; display: flex; flex-direction: column;}
.first-line,.second-line,.third-line { display: flex; align-items: center; width: 100%; height: 33%; /* font-weight: bold; */ font-size: 0.8rem;}
.value { margin-left: auto; margin-right: 20rpx;}
.high-price,.volume,.volume-ratio,.low-price,.amount,.market-earn,.open-price,.turnover-ratio,.market-value { display: flex; flex: 1;}
.stock-chart { width: 100%;}
.stock-kline-tab { display: flex; width: 100%; height: 80rpx; /* border: 1px solid black; */}
.tab-day,.tab-month,.tab-time,.tab-week,.tab-more,.tab-setting { display: flex; justify-content: center; align-items: center; flex: 1; font-size: 0.8rem; color: #6a6a6a; position: relative;}
/* 向下小三角样式 */.arrow-down { width: 0; height: 0; border-left: 10rpx solid transparent; border-right: 10rpx solid transparent; border-bottom: 12rpx solid currentColor; margin-left: 8rpx; display: inline-block;}
.arrow-up { width: 0; height: 0; border-left: 10rpx solid transparent; border-right: 10rpx solid transparent; border-top: 12rpx solid currentColor; margin-left: 8rpx; display: inline-block;}
.arrow-left{ width: 0; height: 0; border-top: 10rpx solid transparent; border-bottom: 10rpx solid transparent; border-right: 12rpx solid currentColor; margin-left: 8rpx; display: inline-block;}
.arrow-right{ width: 0; height: 0; border-top: 10rpx solid transparent; border-bottom: 10rpx solid transparent; border-left: 12rpx solid currentColor; margin-left: 8rpx; display: inline-block;}
.moreTabsContainer { width: 100%; display: flex; gap: 5rpx; margin-bottom: 5rpx;}
.moreTabItem { flex: 1; padding: 10rpx 20rpx; border-radius: 10rpx; background-color: white; font-size: 0.85rem; color: #6a6a6a; text-align: center;}
.tab-selected { color: #db1f1d;}
.tab-selected::after { content: ""; position: absolute; bottom: 10rpx; left: 50%; transform: translateX(-50%); width: 40rpx; height: 5rpx; background-color: #db1f1d;}
.tab-setting-img { width: 30rpx; height: 30rpx;}
.time-chart-container { width: 100%; min-height: 400rpx; background-color: #ffffff; box-sizing: border-box;}
.stock-chart { display: flex; flex-direction: column; align-items: center;}
.kline-chart-container { width: 100%; height: 400rpx; background-color: #ffffff; padding: 20rpx; box-sizing: border-box; display: flex; justify-content: center; align-items: center;}
.bottomTool { width: 100%; height: 150rpx; position: fixed; bottom: 0; background-color: white; display: flex; box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);}
.index,.function,.favorites { flex: 1; display: flex; justify-content: center; align-items: center; flex-direction: column; font-size: 12px; transition: all 0.2s ease;}
.index:active,.function:active,.favorites:active { background-color: rgba(99, 99, 99, 0.5); transform: scale(0.95); transition: all 0.1s ease;}
.icon { width: 40rpx; height: 40rpx;}</style>
|