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
2673 lines
78 KiB
<!-- @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" @click="toLeftPage()"> </view>
|
|
<view class="stock-id">
|
|
<view class="stock-name"> {{ stockInformation.stockName }} </view>
|
|
<view class="stock-code"> {{ stockInformation.stockCode }} </view>
|
|
</view>
|
|
<view class="arrow-right" @click="toRightPage()"> </view>
|
|
</view>
|
|
<image class="search" src="/static/marketSituation-image/search.png" mode="搜索" @click="search()"> </image>
|
|
<view class="more" @click="getMore()">···</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 || klineTab === 3 || klineTab === 4 || klineTab === 5 || klineTab === 6 || klineTab === 7 || klineTab === 8 || klineTab === 9" 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 === 10">
|
|
<button @click="startTcp()">接收消息</button>
|
|
<button @click="sendStopTimeData()">停止消息</button>
|
|
<button @click="sendTcpMessage('real_time')">实时行情推送</button>
|
|
<button @click="sendTcpMessage('init_real_time')">初始化获取行情历史数据</button>
|
|
<button @click="sendTcpMessage('stop_real_time')">停止实时推送</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";
|
|
import { onLoad } from "@dcloudio/uni-app";
|
|
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";
|
|
import tcpConnection, { TCPConnection, TCP_CONFIG } from "@/api/tcpConnection.js";
|
|
|
|
// TCP相关响应式变量
|
|
const tcpConnected = ref(false);
|
|
const connectionListener = ref(null);
|
|
const messageListener = ref(null);
|
|
|
|
// 股票来源
|
|
const currentStockFrom = ref();
|
|
// 当前股票位置
|
|
const currentStockIndex = ref(-1);
|
|
// 股票信息栏变量
|
|
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(1);
|
|
const startTcp = () => {
|
|
try {
|
|
removeTcpListeners();
|
|
disconnectTcp();
|
|
initTcpListeners();
|
|
connectTcp();
|
|
} catch (error) {
|
|
console.error("建立连接并设置监听出错:", error);
|
|
uni.showToast({
|
|
title: "建立连接并设置监听",
|
|
icon: "none",
|
|
duration: 1500,
|
|
});
|
|
}
|
|
};
|
|
|
|
const sendStopTimeData = () => {
|
|
disconnectTcp();
|
|
removeTcpListeners();
|
|
};
|
|
|
|
// 确定股票数据的颜色方法
|
|
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 (klineTab.value) {
|
|
sendTcpMessage("stop_real_time");
|
|
}
|
|
switch (klineTab.value) {
|
|
case 1:
|
|
sendTcpMessage("init_real_time");
|
|
break;
|
|
case 2:
|
|
sendTcpMessage("daily_data");
|
|
break;
|
|
case 3:
|
|
sendTcpMessage("weekly_data");
|
|
break;
|
|
case 4:
|
|
sendTcpMessage("monthly_data");
|
|
break;
|
|
case 5:
|
|
sendTcpMessage("daily_one_minutes_data");
|
|
break;
|
|
case 6:
|
|
sendTcpMessage("daily_five_minutes_data");
|
|
break;
|
|
case 7:
|
|
sendTcpMessage("daily_fifteen_minutes_data");
|
|
break;
|
|
case 8:
|
|
sendTcpMessage("daily_thirty_minutes_data");
|
|
break;
|
|
case 9:
|
|
sendTcpMessage("daily_sixty_minutes_data");
|
|
break;
|
|
case 10:
|
|
uni.showToast({
|
|
title: "暂无季K数据",
|
|
icon: "none",
|
|
duration: 2000,
|
|
});
|
|
break;
|
|
case 11:
|
|
uni.showToast({
|
|
title: "暂无年K数据",
|
|
icon: "none",
|
|
duration: 2000,
|
|
});
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
initCanvas();
|
|
// startAddDataTimer();
|
|
};
|
|
// 返回按钮
|
|
const backToHomepage = () => {
|
|
const pages = getCurrentPages();
|
|
if (pages.length > 1) {
|
|
uni.navigateBack();
|
|
} else {
|
|
// 如果没有上一页,跳转到首页
|
|
uni.reLaunch({
|
|
url: "/pages/home/home",
|
|
});
|
|
}
|
|
};
|
|
|
|
const toLeftPage = () => {
|
|
if(currentStockFrom.value == "marketOverview"){
|
|
return;
|
|
}
|
|
if (currentStockIndex.value > 0) {
|
|
currentStockIndex.value--;
|
|
// updateStockInformation();
|
|
} else {
|
|
uni.showToast({
|
|
title: "没有更多股票了",
|
|
icon: "none",
|
|
duration: 1000,
|
|
});
|
|
}
|
|
};
|
|
|
|
const toRightPage = () => {
|
|
if (currentStockIndex.value < stockList.length - 1) {
|
|
currentStockIndex.value++;
|
|
// updateStockInformation();
|
|
} else {
|
|
uni.showToast({
|
|
title: "没有更多股票了",
|
|
icon: "none",
|
|
duration: 1000,
|
|
});
|
|
}
|
|
};
|
|
|
|
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;
|
|
console.log("data", data);
|
|
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) {
|
|
console.log("stockInfomaton.lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
// 设置标签样式
|
|
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 {
|
|
return;
|
|
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();
|
|
});
|
|
|
|
// 初始化TCP监听器
|
|
const initTcpListeners = () => {
|
|
// 创建连接状态监听器并保存引用
|
|
connectionListener.value = (status, result) => {
|
|
tcpConnected.value = status === "connected";
|
|
console.log("TCP连接状态变化:", status, tcpConnected.value);
|
|
|
|
// 显示连接状态提示
|
|
uni.showToast({
|
|
title: status === "connected" ? "TCP连接成功" : "TCP连接断开",
|
|
icon: status === "connected" ? "success" : "none",
|
|
duration: 2000,
|
|
});
|
|
|
|
if (status === "connected") {
|
|
if (klineTab.value == 1) {
|
|
sendTcpMessage("init_real_time");
|
|
}
|
|
}
|
|
};
|
|
|
|
// 创建消息监听器并保存引用
|
|
messageListener.value = (type, message, parsedArray) => {
|
|
const messageObj = {
|
|
type: type,
|
|
content: message,
|
|
parsedArray: parsedArray,
|
|
timestamp: new Date().toLocaleTimeString(),
|
|
direction: "received",
|
|
};
|
|
|
|
// 解析股票数据
|
|
parseStockData(message);
|
|
};
|
|
|
|
// 注册监听器
|
|
tcpConnection.onConnectionChange(connectionListener.value);
|
|
tcpConnection.onMessage(messageListener.value);
|
|
};
|
|
|
|
// 连接TCP服务器
|
|
const connectTcp = () => {
|
|
console.log("开始连接TCP服务器...");
|
|
tcpConnection.connect();
|
|
};
|
|
|
|
// 断开TCP连接
|
|
const disconnectTcp = () => {
|
|
console.log("断开TCP连接...");
|
|
tcpConnection.disconnect();
|
|
tcpConnected.value = false;
|
|
};
|
|
|
|
// 发送TCP消息
|
|
const sendTcpMessage = (command) => {
|
|
let messageData;
|
|
switch (command) {
|
|
// 实时行情推送
|
|
case "real_time":
|
|
messageData = {
|
|
command: "real_time",
|
|
stock_code: "SH.000001",
|
|
};
|
|
break;
|
|
// 初始化获取行情历史数据
|
|
case "init_real_time":
|
|
messageData = {
|
|
command: "init_real_time",
|
|
stock_code: "SH.000001",
|
|
};
|
|
break;
|
|
case "stop_real_time":
|
|
messageData = {
|
|
command: "stop_real_time",
|
|
};
|
|
break;
|
|
// 股票列表
|
|
case "stock_list":
|
|
messageData = {
|
|
command: "stock_list",
|
|
};
|
|
break;
|
|
// 日线数据
|
|
case "daily_data":
|
|
messageData = {
|
|
command: "daily_data",
|
|
stock_code: "GBPAUD.FXCM",
|
|
start_date: "20250801",
|
|
end_date: "20251029",
|
|
};
|
|
break;
|
|
// 周线数据
|
|
case "weekly_data":
|
|
messageData = {
|
|
command: "weekly_data",
|
|
stock_code: "000001.SZ",
|
|
start_date: "2024912",
|
|
end_date: "20251029",
|
|
};
|
|
break;
|
|
// 周线数据
|
|
case "monthly_data":
|
|
messageData = {
|
|
command: "monthly_data",
|
|
stock_code: "000001.SZ",
|
|
start_date: "20201130",
|
|
end_date: "20251029",
|
|
};
|
|
break;
|
|
// 1分钟线数据
|
|
case "daily_one_minutes_data":
|
|
messageData = {
|
|
command: "daily_one_minutes_data",
|
|
stock_code: "000001.SZ",
|
|
};
|
|
break;
|
|
// 5分钟线数据
|
|
case "daily_five_minutes_data":
|
|
messageData = {
|
|
command: "daily_five_minutes_data",
|
|
stock_code: "000001.SZ",
|
|
};
|
|
break;
|
|
// 15分钟线数据
|
|
case "daily_fifteen_minutes_data":
|
|
messageData = {
|
|
command: "daily_fifteen_minutes_data",
|
|
stock_code: "000001.SZ",
|
|
};
|
|
break;
|
|
// 30分钟线数据
|
|
case "daily_thirty_minutes_data":
|
|
messageData = {
|
|
command: "daily_thirty_minutes_data",
|
|
stock_code: "000001.SZ",
|
|
};
|
|
break;
|
|
// 60分钟线数据
|
|
case "daily_sixty_minutes_data":
|
|
messageData = {
|
|
command: "daily_sixty_minutes_data",
|
|
stock_code: "000001.SZ",
|
|
};
|
|
break;
|
|
case "batch_real_time":
|
|
messageData = {
|
|
command: "batch_real_time",
|
|
stock_codes: ["SH.000001", "SH.000002", "SH.000003", "SH.000004", "SH.000005"],
|
|
};
|
|
break;
|
|
case "help":
|
|
messageData = {
|
|
command: "help",
|
|
};
|
|
break;
|
|
}
|
|
if (!messageData) {
|
|
uni.showToast({
|
|
title: "命令不存在",
|
|
icon: "none",
|
|
duration: 1000,
|
|
});
|
|
return;
|
|
} else {
|
|
try {
|
|
// 发送消息
|
|
const success = tcpConnection.send(messageData);
|
|
if (success) {
|
|
console.log("home发送TCP消息:", messageData);
|
|
uni.showToast({
|
|
title: "消息发送成功",
|
|
icon: "success",
|
|
duration: 1000,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("发送TCP消息时出错:", error);
|
|
uni.showToast({
|
|
title: "消息发送失败",
|
|
icon: "none",
|
|
duration: 1000,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// 获取TCP连接状态
|
|
const getTcpStatus = () => {
|
|
const status = tcpConnection.getConnectionStatus();
|
|
uni.showModal({
|
|
title: "TCP连接状态",
|
|
content: `当前状态: ${status ? "已连接" : "未连接"}`,
|
|
showCancel: false,
|
|
});
|
|
};
|
|
|
|
let isMorePacket = {
|
|
init_real_time: false,
|
|
daily_data: false,
|
|
weekly_data: false,
|
|
monthly_data: false,
|
|
daily_one_minutes_data: false,
|
|
daily_five_minutes_data: false,
|
|
daily_fifteen_minutes_data: false,
|
|
daily_thirty_minutes_data: false,
|
|
daily_sixty_minutes_data: false,
|
|
};
|
|
let receivedMessage;
|
|
const findJsonPacket = (message, command) => {
|
|
let jsonStartIndex = 0;
|
|
let jsonEndIndex = message.indexOf(command);
|
|
let jsonStartCount = 0;
|
|
let jsonEndCount = 0;
|
|
|
|
for (let i = 0; i < message.length - 1; ++i) {
|
|
if (message[i] == "{") {
|
|
jsonStartCount++;
|
|
if (jsonStartCount == 2) {
|
|
jsonStartIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
for (let i = message.indexOf(command); i >= 0; --i) {
|
|
if (message[i] == "}" || i == jsonStartIndex) {
|
|
jsonEndCount++;
|
|
if (jsonEndCount == 1) {
|
|
jsonEndIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 检查JSON字符串是否有效
|
|
if (jsonStartIndex >= jsonEndIndex) {
|
|
return { error: true };
|
|
}
|
|
|
|
return { json: JSON.parse(message.substring(jsonStartIndex, jsonEndIndex + 1)) };
|
|
};
|
|
|
|
// 根据timeData中最后一个时间生成下一个时间
|
|
const generateNextTime = () => {
|
|
if (timeData.value.length === 0) {
|
|
return "09:30"; // 如果没有数据,返回开盘时间
|
|
}
|
|
|
|
const lastTime = timeData.value[timeData.value.length - 1].time;
|
|
if (!lastTime) {
|
|
return "09:30";
|
|
}
|
|
|
|
// 解析时间字符串,格式为 "HH:MM"
|
|
const [hours, minutes] = lastTime.split(":").map(Number);
|
|
|
|
// 计算下一分钟
|
|
let nextMinutes = minutes + 1;
|
|
let nextHours = hours;
|
|
|
|
// 处理分钟进位
|
|
if (nextMinutes >= 60) {
|
|
nextMinutes = 0;
|
|
nextHours += 1;
|
|
}
|
|
|
|
// 处理小时进位(24小时制)
|
|
if (nextHours >= 24) {
|
|
nextHours = 0;
|
|
}
|
|
|
|
// 格式化为 "HH:MM" 格式
|
|
const formattedHours = nextHours.toString().padStart(2, "0");
|
|
const formattedMinutes = nextMinutes.toString().padStart(2, "0");
|
|
|
|
return `${formattedHours}:${formattedMinutes}`;
|
|
};
|
|
// 解析TCP股票数据
|
|
const parseStockData = (message) => {
|
|
try {
|
|
console.log("进入parseStockData, message类型:", typeof message);
|
|
|
|
let parsedMessage;
|
|
// 如果isMorePacket是true,说明正在接受分包数据,无条件接收
|
|
// 如果message是字符串且以{开头,说明是JSON字符串,需要解析
|
|
// 如果不属于以上两种情况,说明是普通字符串,不预解析
|
|
if (message.includes("欢迎连接到股票数据服务器")) {
|
|
console.log("服务器命令列表,不予处理");
|
|
return;
|
|
}
|
|
if (message.includes("real_time")) {
|
|
let startIndex = 0;
|
|
let endIndex = message.length;
|
|
for (let i = 0; i < message.length - 1; ++i) {
|
|
if (message[i] == "{") {
|
|
startIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
for (let i = message.length - 1; i >= 0; --i) {
|
|
if (message[i] == "}") {
|
|
endIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
parsedMessage = JSON.parse(message.substring(startIndex, endIndex + 1));
|
|
console.log("实时数据解析", parsedMessage);
|
|
// 处理实时数据
|
|
timeData.value.push({
|
|
time: generateNextTime(),
|
|
price: parsedMessage.current_price,
|
|
volume: parsedMessage.volume,
|
|
amount: parsedMessage.amount,
|
|
});
|
|
// 实时更新股票信息
|
|
stockInformation.value.currentPrice = parsedMessage.current_price;
|
|
stockInformation.value.openPrice = parsedMessage.open_price;
|
|
stockInformation.value.closePrice = parsedMessage.close_price;
|
|
stockInformation.value.highPrice = parsedMessage.high_price;
|
|
stockInformation.value.lowPrice = parsedMessage.low_price;
|
|
stockInformation.value.volume = parsedMessage.volume;
|
|
stockInformation.value.amount = parsedMessage.amount;
|
|
stockInformation.value.turnoverRatio = parsedMessage.turnover_ratio;
|
|
stockInformation.value.marketValue = parsedMessage.total_market_value;
|
|
stockInformation.value.currentValue = stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice;
|
|
stockInformation.value.currentRatio = ((stockInformation.value.currentPrice - stockInformation.value.lastDayStockClosePrice) / stockInformation.value.lastDayStockClosePrice) * 100;
|
|
console.log("重绘画面");
|
|
drawChart();
|
|
if (timeData.value.length >= 240) {
|
|
sendTcpMessage("stop_real_time");
|
|
}
|
|
return;
|
|
} else if ((typeof message === "string" && message.includes("init_real_data_start")) || isMorePacket.init_real_time) {
|
|
if (typeof message === "string" && message.includes("init_real_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.init_real_time = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("init_real_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.init_real_time = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "init_real_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "daily_data") {
|
|
timeData.value = parsedMessage.data;
|
|
stockInformation.value.lastDayStockClosePrice = parsedMessage.pre_close;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
sendTcpMessage("stop_real_time");
|
|
sendTcpMessage("real_time");
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("daily_data_start")) || isMorePacket.daily_data) {
|
|
if (typeof message === "string" && message.includes("daily_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.daily_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("daily_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.daily_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "daily_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "daily_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.ask_open,
|
|
close: item.ask_close,
|
|
high: item.ask_high,
|
|
low: item.ask_low,
|
|
volume: item.tick_qty,
|
|
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,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("weekly_data_start")) || isMorePacket.weekly_data) {
|
|
if (typeof message === "string" && message.includes("weekly_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.weekly_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("weekly_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.weekly_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "weekly_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "weekly_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.bid_open,
|
|
close: item.bid_close,
|
|
high: item.bid_high,
|
|
low: item.bid_low,
|
|
volume: item.vol,
|
|
amount: item.amount,
|
|
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,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("monthly_data_start")) || isMorePacket.monthly_data) {
|
|
if (typeof message === "string" && message.includes("monthly_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.monthly_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("monthly_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.monthly_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "monthly_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "monthly_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.bid_open,
|
|
close: item.bid_close,
|
|
high: item.bid_high,
|
|
low: item.bid_low,
|
|
volume: item.vol,
|
|
amount: item.amount,
|
|
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,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("daily_one_minutes_data_start")) || isMorePacket.daily_one_minutes_data) {
|
|
if (typeof message === "string" && message.includes("daily_one_minutes_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.daily_one_minutes_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("daily_one_minutes_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.daily_one_minutes_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "daily_one_minutes_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "daily_one_minutes_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.open,
|
|
close: item.close,
|
|
high: Math.max(item.low, item.high),
|
|
low: Math.min(item.low, item.high),
|
|
volume: item.volume,
|
|
amount: item.amount,
|
|
date: item.timestamp,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("daily_five_minutes_data_start")) || isMorePacket.daily_five_minutes_data) {
|
|
if (typeof message === "string" && message.includes("daily_five_minutes_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.daily_five_minutes_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("daily_five_minutes_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.daily_five_minutes_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "daily_five_minutes_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "daily_five_minutes_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.open,
|
|
close: item.close,
|
|
high: Math.max(item.low, item.high),
|
|
low: Math.min(item.low, item.high),
|
|
volume: item.volume,
|
|
amount: item.amount,
|
|
date: item.timestamp,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("daily_fifteen_minutes_data_start")) || isMorePacket.daily_fifteen_minutes_data) {
|
|
if (typeof message === "string" && message.includes("daily_fifteen_minutes_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.daily_fifteen_minutes_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("daily_fifteen_minutes_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.daily_fifteen_minutes_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "daily_fifteen_minutes_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "daily_fifteen_minutes_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.open,
|
|
close: item.close,
|
|
high: Math.max(item.low, item.high),
|
|
low: Math.min(item.low, item.high),
|
|
volume: item.volume,
|
|
amount: item.amount,
|
|
date: item.timestamp,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("daily_thirty_minutes_data_start")) || isMorePacket.daily_thirty_minutes_data) {
|
|
if (typeof message === "string" && message.includes("daily_thirty_minutes_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.daily_thirty_minutes_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("daily_thirty_minutes_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.daily_thirty_minutes_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "daily_thirty_minutes_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "daily_thirty_minutes_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.open,
|
|
close: item.close,
|
|
high: Math.max(item.low, item.high),
|
|
low: Math.min(item.low, item.high),
|
|
volume: item.volume,
|
|
amount: item.amount,
|
|
date: item.timestamp,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else if ((typeof message === "string" && message.includes("daily_sixty_minutes_data_start")) || isMorePacket.daily_sixty_minutes_data) {
|
|
if (typeof message === "string" && message.includes("daily_sixty_minutes_data_start")) {
|
|
console.log("开始接受分包数据");
|
|
receivedMessage = "";
|
|
} else {
|
|
console.log("接收分包数据过程中");
|
|
}
|
|
isMorePacket.daily_sixty_minutes_data = true;
|
|
receivedMessage += message;
|
|
// 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
|
|
if (receivedMessage.includes("daily_sixty_minutes_data_complete")) {
|
|
console.log("接受分包数据结束");
|
|
isMorePacket.daily_sixty_minutes_data = false;
|
|
|
|
console.log("展示数据", receivedMessage);
|
|
|
|
const result = findJsonPacket(receivedMessage, "daily_sixty_minutes_data_complete");
|
|
if (result.error) {
|
|
throw new Error("解析JSON字符串失败");
|
|
} else {
|
|
parsedMessage = result.json;
|
|
console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
|
|
if (parsedMessage.type === "daily_sixty_minutes_data") {
|
|
klineData.value = parsedMessage.data.map((item) => ({
|
|
open: item.open,
|
|
close: item.close,
|
|
high: Math.max(item.low, item.high),
|
|
low: Math.min(item.low, item.high),
|
|
volume: item.volume,
|
|
amount: item.amount,
|
|
date: item.timestamp,
|
|
}));
|
|
stockInformation.value.lastDayStockClosePrice = klineData.value[klineData.value.length - 2].close;
|
|
touchState.offset = canvasWidth.value / klineData.value.length / 2;
|
|
console.log("lastDayStockClosePrice", stockInformation.value.lastDayStockClosePrice);
|
|
drawChart();
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// 没有通过JSON解析判断,说明不是需要的数据
|
|
console.log("不是需要的数据,不做处理");
|
|
}
|
|
} catch (error) {
|
|
console.error("解析TCP股票数据失败:", error.message);
|
|
console.error("错误详情:", error);
|
|
}
|
|
};
|
|
|
|
// 移除TCP监听器
|
|
const removeTcpListeners = () => {
|
|
if (connectionListener.value) {
|
|
tcpConnection.removeConnectionListener(connectionListener.value);
|
|
connectionListener.value = null;
|
|
console.log("已移除TCP连接状态监听器");
|
|
}
|
|
|
|
if (messageListener.value) {
|
|
tcpConnection.removeMessageListener(messageListener.value);
|
|
messageListener.value = null;
|
|
console.log("已移除TCP消息监听器");
|
|
}
|
|
};
|
|
|
|
// 定时器标识(用于清除定时器)
|
|
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秒
|
|
};
|
|
|
|
onLoad((options) => {
|
|
console.log("页面接收到的参数:", options);
|
|
|
|
// 处理通过stockInformation参数传递的复杂对象
|
|
if (options.stockInformation) {
|
|
try {
|
|
const stockData = JSON.parse(decodeURIComponent(options.stockInformation));
|
|
console.log("解析的股票数据:", stockData);
|
|
|
|
// 更新stockInformation
|
|
if (stockData) {
|
|
stockInformation.value.stockName = stockData.stockName;
|
|
stockInformation.value.stockCode = stockData.stockCode;
|
|
}
|
|
} catch (error) {
|
|
console.error("解析股票数据失败:", error);
|
|
}
|
|
}
|
|
|
|
// 处理index参数(股票在列表中的位置)
|
|
if (options.index !== undefined) {
|
|
const stockIndex = parseInt(options.index);
|
|
console.log("股票在列表中的索引:", stockIndex);
|
|
// 将index保存到响应式变量中,用于后续的左右切换功能
|
|
currentStockIndex.value = stockIndex;
|
|
}
|
|
|
|
// 处理stockFrom参数(股票来源)
|
|
if (options.stockFrom) {
|
|
currentStockFrom.value = options.stockFrom;
|
|
}
|
|
});
|
|
|
|
// 保存定时器,用于页面卸载时清理
|
|
onUnmounted(() => {
|
|
removeTcpListeners();
|
|
disconnectTcp();
|
|
if (timer) {
|
|
console.log("卸载定时器");
|
|
clearInterval(timer);
|
|
}
|
|
});
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
console.log("步骤1: 初始化系统信息");
|
|
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);
|
|
|
|
initTcpListeners();
|
|
await nextTick();
|
|
// 开始连接
|
|
startTcp();
|
|
|
|
if (timeData.value && timeData.value.length > 0) {
|
|
// 当前股价
|
|
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);
|
|
} else {
|
|
console.warn("没有时间数据,跳过股票信息计算");
|
|
}
|
|
await nextTick();
|
|
setTimeout(() => {
|
|
initCanvas();
|
|
}, 100);
|
|
console.log("所有初始化步骤完成");
|
|
} catch (error) {
|
|
console.error("初始化过程中出现错误:", error);
|
|
}
|
|
});
|
|
</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>
|