674 lines
17 KiB
Vue
674 lines
17 KiB
Vue
<template>
|
||
<div class="custom-echarts">
|
||
<div>
|
||
<div class="echarts-header">
|
||
<!-- 标题区域 -->
|
||
<div class="title-section">
|
||
<div class="title-decoration"></div>
|
||
<div class="stock-title">
|
||
<span>{{ t("historic_stock.echarts.title") }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="echarts-search-area">
|
||
<div class="echarts-search-byRange">
|
||
<text style="font-size: 0.9rem; font-weight: 400; color: #666666">
|
||
{{ t("historic_stock.echarts.range") }}
|
||
</text>
|
||
<div class="search-range-list">
|
||
<div
|
||
class="search-range-list-each"
|
||
v-for="(item, index) in searchRangeOptions"
|
||
:key="index"
|
||
:class="{ activeRange: state.activeRange === item.key }"
|
||
@click="changeSearchRange(item.key)"
|
||
>
|
||
<span>{{ item.label }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="echarts-search-byDate">
|
||
<n-date-picker
|
||
v-model:value="state.dateRange"
|
||
type="daterange"
|
||
:is-date-disabled="isDateDisabled"
|
||
@update:value="handleDateRangeChange"
|
||
input-readonly
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="myEcharts" class="myChart"></div>
|
||
</div>
|
||
</template>
|
||
<script setup>
|
||
import { onMounted, onBeforeUnmount, watch, reactive, computed } from "vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import * as echarts from "echarts";
|
||
import { NDatePicker, NIcon } from "naive-ui";
|
||
import { ArrowForwardOutline } from "@vicons/ionicons5";
|
||
import axios from "axios";
|
||
|
||
const { t, locale } = useI18n();
|
||
const state = reactive({
|
||
searchRange: ["1m", "3m", "YTD", "1Y", "5Y", "10Y", "Max"],
|
||
dateRange: [new Date("2009-10-07").getTime(), new Date().getTime()],
|
||
activeRange: "",
|
||
});
|
||
|
||
const searchRangeOptions = computed(() => [
|
||
{ label: t("historic_stock.echarts.1m"), key: "1m" },
|
||
{ label: t("historic_stock.echarts.3m"), key: "3m" },
|
||
{ label: t("historic_stock.echarts.ytd_short"), key: "YTD" },
|
||
{ label: t("historic_stock.echarts.1y"), key: "1Y" },
|
||
{ label: t("historic_stock.echarts.5y"), key: "5Y" },
|
||
{ label: t("historic_stock.echarts.10y"), key: "10Y" },
|
||
{ label: t("historic_stock.echarts.max"), key: "Max" },
|
||
]);
|
||
|
||
let myCharts = null;
|
||
let historicData = [];
|
||
let xAxisData = [];
|
||
|
||
//初始化eCharts
|
||
const initEcharts = (data) => {
|
||
historicData = data;
|
||
xAxisData = data.map((item) => {
|
||
return new Date(item.date).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
});
|
||
const yAxisData = data.map((item) => item.price);
|
||
// console.error(xAxisData, yAxisData)
|
||
// 基于准备好的dom,初始化echarts实例
|
||
myCharts = echarts.init(document.getElementById("myEcharts"), null, {
|
||
renderer: "canvas",
|
||
useDirtyRect: true,
|
||
});
|
||
// 绘制图表
|
||
myCharts.setOption({
|
||
animation: false,
|
||
progressive: 500,
|
||
progressiveThreshold: 3000,
|
||
// title: {
|
||
// text: 'FiEE, Inc. Stock Price History',
|
||
// },
|
||
grid: {
|
||
left: "8%", // 或 '2%',根据实际情况调整
|
||
right: "12%", // 给右侧y轴留空间,数值可根据y轴label宽度调整
|
||
},
|
||
tooltip: {
|
||
trigger: "axis",
|
||
axisPointer: {
|
||
type: "line",
|
||
label: {
|
||
backgroundColor: "#6a7985",
|
||
},
|
||
},
|
||
formatter: function (params) {
|
||
const p = params[0];
|
||
return `<span style="font-size: 1.1rem; font-weight: 600;">${
|
||
p.axisValue
|
||
}</span><br/><span style="font-size: 0.9rem; font-weight: 400;">${t(
|
||
"historic_stock.echarts.price"
|
||
)}: ${p.data}</span>`;
|
||
},
|
||
triggerOn: "mousemove",
|
||
confine: true,
|
||
hideDelay: 1500,
|
||
},
|
||
xAxis: {
|
||
data: xAxisData,
|
||
type: "category",
|
||
boundaryGap: false,
|
||
inverse: true,
|
||
axisLine: {
|
||
lineStyle: {
|
||
color: "#CCD6EB",
|
||
},
|
||
},
|
||
axisLabel: {
|
||
color: "#323232",
|
||
fontWeight: "bold",
|
||
interval: "auto",
|
||
hideOverlap: true,
|
||
},
|
||
},
|
||
yAxis: {
|
||
type: "value",
|
||
position: "right",
|
||
interval: 25,
|
||
// max: 75.0,
|
||
show: true,
|
||
axisLabel: {
|
||
color: "#323232",
|
||
fontWeight: "bold",
|
||
formatter: function (value) {
|
||
return value > 0 ? value.toFixed(2) : value;
|
||
},
|
||
},
|
||
},
|
||
series: [
|
||
{
|
||
data: yAxisData,
|
||
type: "line",
|
||
sampling: "lttb",
|
||
symbol: "none",
|
||
lineStyle: {
|
||
color: "#CC346C",
|
||
},
|
||
areaStyle: {
|
||
color: {
|
||
type: "linear",
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [
|
||
{
|
||
offset: 0,
|
||
color: "#CC346C",
|
||
},
|
||
{
|
||
offset: 1,
|
||
color: "#F4F6F8",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
markPoint: {
|
||
symbol: "circle",
|
||
symbolSize: 20,
|
||
itemStyle: {
|
||
color: {
|
||
type: "radial",
|
||
x: 0.5,
|
||
y: 0.5,
|
||
r: 0.5,
|
||
colorStops: [
|
||
{ offset: 0, color: "#CC346C" },
|
||
{ offset: 0.4, color: "white" },
|
||
{ offset: 0.4, color: "white" },
|
||
{ offset: 0.6, color: "rgba(204, 52, 108, 0.30)" },
|
||
{ offset: 0.8, color: "rgba(204, 52, 108, 0.30)" },
|
||
{ offset: 1, color: "rgba(255, 123, 172, 0)" },
|
||
],
|
||
},
|
||
},
|
||
data: [],
|
||
},
|
||
progressive: 500,
|
||
progressiveThreshold: 3000,
|
||
large: true,
|
||
largeThreshold: 2000,
|
||
},
|
||
],
|
||
|
||
dataZoom: [
|
||
{
|
||
type: "inside",
|
||
},
|
||
{
|
||
type: "slider",
|
||
show: true,
|
||
dataBackground: {
|
||
lineStyle: {
|
||
color: "#CC346C",
|
||
},
|
||
areaStyle: {
|
||
color: {
|
||
type: "linear",
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [
|
||
{ offset: 1, color: "#CC346C" },
|
||
{ offset: 0, color: "#F4F6F8" },
|
||
],
|
||
},
|
||
},
|
||
},
|
||
selectedDataBackground: {
|
||
lineStyle: {
|
||
color: "#CC346C",
|
||
},
|
||
areaStyle: {
|
||
color: {
|
||
type: "linear",
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [
|
||
{ offset: 1, color: "#CC346C" },
|
||
{ offset: 0, color: "#F4F6F8" },
|
||
],
|
||
},
|
||
},
|
||
},
|
||
fillerColor: "rgba(44, 98, 136, 0.3)",
|
||
realtime: false,
|
||
},
|
||
],
|
||
});
|
||
|
||
// 监听 showTip 事件,动态显示 markPoint
|
||
myCharts.on("showTip", function (params) {
|
||
if (params) {
|
||
const dataIndex = params.dataIndex;
|
||
const x = myCharts.getOption().xAxis[0].data[dataIndex];
|
||
const y = myCharts.getOption().series[0].data[dataIndex];
|
||
myCharts.setOption({
|
||
series: [
|
||
{
|
||
markPoint: {
|
||
data: [{ coord: [x, y] }],
|
||
},
|
||
},
|
||
],
|
||
});
|
||
}
|
||
});
|
||
// 鼠标移出时,清除 markPoint
|
||
myCharts.on("globalout", function () {
|
||
myCharts.setOption({
|
||
series: [
|
||
{
|
||
markPoint: {
|
||
data: [],
|
||
},
|
||
},
|
||
],
|
||
});
|
||
});
|
||
myCharts.on("dataZoom", function (params) {
|
||
// 获取当前 dataZoom 范围
|
||
const option = myCharts.getOption();
|
||
const xAxisData = option.xAxis[0].data;
|
||
const dataZoom = option.dataZoom[1] || option.dataZoom[0];
|
||
|
||
// 获取 dataZoom 的 startValue 和 endValue
|
||
let startValue = dataZoom.endValue;
|
||
let endValue = dataZoom.startValue;
|
||
|
||
// 如果是索引,转为日期
|
||
if (typeof startValue === "number") {
|
||
startValue = xAxisData[startValue];
|
||
}
|
||
if (typeof endValue === "number") {
|
||
endValue = xAxisData[endValue];
|
||
}
|
||
|
||
// 更新日期选择器
|
||
state.dateRange = [
|
||
new Date(startValue).getTime(),
|
||
new Date(endValue).getTime(),
|
||
];
|
||
});
|
||
};
|
||
|
||
// 防抖函数
|
||
const debounce = (func, wait) => {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
};
|
||
|
||
// 处理窗口大小变化
|
||
const handleResize = () => {
|
||
if (myCharts) {
|
||
myCharts.resize();
|
||
}
|
||
};
|
||
|
||
// 创建防抖后的 resize 处理函数
|
||
const debouncedResize = debounce(handleResize, 300);
|
||
|
||
onMounted(() => {
|
||
getHistoricalData();
|
||
// 添加窗口 resize 监听
|
||
window.addEventListener("resize", debouncedResize);
|
||
});
|
||
|
||
// 组件卸载时清理
|
||
onBeforeUnmount(() => {
|
||
// 移除 resize 监听
|
||
window.removeEventListener("resize", debouncedResize);
|
||
// 销毁 echarts 实例
|
||
if (myCharts) {
|
||
myCharts.dispose();
|
||
myCharts = null;
|
||
}
|
||
});
|
||
|
||
//获取历史数据
|
||
const getHistoricalData = async () => {
|
||
let now = new Date();
|
||
let toDate =
|
||
now.getFullYear() +
|
||
"-" +
|
||
String(now.getMonth() + 1).padStart(2, "0") +
|
||
"-" +
|
||
String(now.getDate()).padStart(2, "0");
|
||
let url =
|
||
"https://common.szjixun.cn/api/stock/history/base/list?from=2009-10-07&to=" +
|
||
toDate;
|
||
const res = await axios.get(url);
|
||
if (res.status === 200) {
|
||
if (res.data.status === 0) {
|
||
initEcharts(res.data.data);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 适配倒序数据,返回大于等于目标日期的最近一天索引
|
||
function findClosestDateIndex(data, targetDateStr) {
|
||
let left = 0,
|
||
right = data.length - 1;
|
||
const target = new Date(targetDateStr).getTime();
|
||
let res = data.length - 1; // 默认返回最后一个
|
||
while (left <= right) {
|
||
const mid = Math.floor((left + right) / 2);
|
||
const midTime = new Date(data[mid].date).getTime();
|
||
if (midTime > target) {
|
||
left = mid + 1;
|
||
} else {
|
||
res = mid;
|
||
right = mid - 1;
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// 适配倒序数据,返回小于等于目标日期的最近一天索引
|
||
function findClosestDateIndexDescLeft(data, targetDateStr) {
|
||
let left = 0,
|
||
right = data.length - 1;
|
||
const target = new Date(targetDateStr).getTime();
|
||
let res = -1;
|
||
while (left <= right) {
|
||
const mid = Math.floor((left + right) / 2);
|
||
const midTime = new Date(data[mid].date).getTime();
|
||
if (midTime > target) {
|
||
left = mid + 1; // mid 比目标新,往更旧的方向找
|
||
} else {
|
||
res = mid; // mid <= target,记录下来,继续往更新的方向找
|
||
right = mid - 1;
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
//点击切换搜索区间
|
||
const changeSearchRange = (range, dateTime) => {
|
||
state.activeRange = range;
|
||
const now = new Date();
|
||
let startDate = "";
|
||
let endDate = "";
|
||
if (range === "1m") {
|
||
const last = new Date(now);
|
||
last.setMonth(now.getMonth() - 1);
|
||
startDate = last.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
endDate = new Date(new Date()).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
} else if (range === "3m") {
|
||
const last = new Date(now);
|
||
last.setMonth(now.getMonth() - 3);
|
||
startDate = last.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
endDate = new Date(new Date()).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
} else if (range === "YTD") {
|
||
startDate = new Date(now.getFullYear(), 0, 1).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
endDate = new Date(new Date()).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
} else if (range === "1Y") {
|
||
const last = new Date(now);
|
||
last.setFullYear(now.getFullYear() - 1);
|
||
startDate = last.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
endDate = new Date(new Date()).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
} else if (range === "5Y") {
|
||
const last = new Date(now);
|
||
last.setFullYear(now.getFullYear() - 5);
|
||
startDate = last.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
endDate = new Date(new Date()).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
} else if (range === "10Y") {
|
||
const last = new Date(now);
|
||
last.setFullYear(now.getFullYear() - 10);
|
||
startDate = last.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
endDate = new Date(new Date()).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
} else if (range === "Max") {
|
||
startDate = "";
|
||
endDate = "";
|
||
} else if (range === "startDateTime") {
|
||
startDate = dateTime;
|
||
endDate = "";
|
||
} else if (range === "endDateTime") {
|
||
startDate = "";
|
||
endDate = dateTime;
|
||
}
|
||
if (startDate || endDate) {
|
||
// historicData 和 xAxisData 需在 initEcharts 作用域可用
|
||
if (
|
||
typeof historicData !== "undefined" &&
|
||
typeof xAxisData !== "undefined"
|
||
) {
|
||
const zoomOptions = {};
|
||
let newStartTs = state.dateRange[0];
|
||
let newEndTs = state.dateRange[1];
|
||
|
||
if (startDate) {
|
||
const idx = findClosestDateIndex(historicData, startDate);
|
||
const startValue = new Date(historicData[idx].date).toLocaleDateString(
|
||
"en-US",
|
||
{
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
}
|
||
);
|
||
zoomOptions.endValue = startValue;
|
||
newStartTs = new Date(startValue).getTime();
|
||
}
|
||
|
||
if (endDate) {
|
||
const idx = findClosestDateIndexDescLeft(historicData, endDate);
|
||
const endValue = new Date(historicData[idx].date).toLocaleDateString(
|
||
"en-US",
|
||
{
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
}
|
||
);
|
||
zoomOptions.startValue = endValue;
|
||
newEndTs = new Date(endValue).getTime();
|
||
}
|
||
|
||
if (Object.keys(zoomOptions).length > 0) {
|
||
myCharts.setOption({ dataZoom: [zoomOptions, zoomOptions] });
|
||
}
|
||
|
||
state.dateRange = [newStartTs, newEndTs];
|
||
}
|
||
} else {
|
||
myCharts.setOption({
|
||
dataZoom: {
|
||
startValue: "",
|
||
endValue: "",
|
||
},
|
||
});
|
||
|
||
state.dateRange = [new Date("2009-10-07").getTime(), new Date().getTime()];
|
||
}
|
||
};
|
||
|
||
// 禁用日期
|
||
const isDateDisabled = (ts, type, range) => {
|
||
const minDate = new Date("2009-10-06").getTime();
|
||
const maxDate = new Date().getTime();
|
||
|
||
if (ts < minDate || ts > maxDate) {
|
||
return true;
|
||
}
|
||
|
||
if (type === "end" && range && range[0]) {
|
||
return ts < range[0];
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
// 切换搜索区间
|
||
const handleDateRangeChange = (range) => {
|
||
if (range && range[0] && range[1]) {
|
||
const startDate = new Date(range[0]).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
const endDate = new Date(range[1]).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
|
||
changeSearchRange("startDateTime", startDate);
|
||
changeSearchRange("endDateTime", endDate);
|
||
}
|
||
};
|
||
</script>
|
||
<style lang="scss" scoped>
|
||
.custom-echarts {
|
||
.myChart {
|
||
width: 100%;
|
||
height: 25rem;
|
||
}
|
||
|
||
.echarts-header {
|
||
.title-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
padding: 0 16px;
|
||
}
|
||
.title-decoration {
|
||
width: 58px;
|
||
height: 7px;
|
||
background: #ff7bac;
|
||
margin: auto 0;
|
||
margin-top: 43px;
|
||
}
|
||
|
||
.stock-title {
|
||
font-family: "PingFang SC", sans-serif;
|
||
font-weight: 500;
|
||
font-size: 40px;
|
||
line-height: 1.4em;
|
||
letter-spacing: 3%;
|
||
color: #000000;
|
||
}
|
||
|
||
.echarts-search-area {
|
||
padding: 0 16px 0 16px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
|
||
.echarts-search-byRange {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 10px;
|
||
.search-range-list {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 10px;
|
||
.search-range-list-each {
|
||
padding: 5px 10px;
|
||
border-radius: 5px;
|
||
background-color: #f3f4f6;
|
||
cursor: pointer;
|
||
span {
|
||
font-size: 0.9rem;
|
||
}
|
||
}
|
||
.activeRange {
|
||
color: #fff;
|
||
background: #ff7bac;
|
||
}
|
||
}
|
||
}
|
||
|
||
.echarts-search-byDate {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
gap: 0.4rem;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|