Compare commits
48 Commits
main
...
zhangyuans
Author | SHA1 | Date | |
---|---|---|---|
|
8566012575 | ||
|
26e7047359 | ||
|
b3ab1781c3 | ||
|
d86ad2c832 | ||
|
79210d8402 | ||
|
56609fed31 | ||
|
faac577341 | ||
|
ee596a518f | ||
|
bbc63346a1 | ||
|
7466bcdcf7 | ||
|
f6b2956ac3 | ||
|
6abcba798f | ||
|
850a3169c2 | ||
|
bd2225f59b | ||
|
4f59eb52e1 | ||
|
ad91c54d8d | ||
|
364a7e4e3e | ||
|
f1af717483 | ||
|
c11364fa42 | ||
|
62f9b8f2e1 | ||
|
7360392044 | ||
|
63c39cfb9a | ||
|
6679da97f6 | ||
|
60d228c3d8 | ||
|
000b23f4a8 | ||
|
51d2364e38 | ||
|
52d9083813 | ||
|
54b1d1551d | ||
|
009d6d4d67 | ||
|
ea9ad3d08e | ||
|
8d38e839ca | ||
|
e3196f5619 | ||
|
fd8faedc12 | ||
|
7187503fcf | ||
|
a3209aa170 | ||
|
df01e9b81b | ||
|
be8d2ca4d4 | ||
|
7b4d234c48 | ||
|
318f850885 | ||
|
fd8b03ad3e | ||
|
7a7751567d | ||
|
eeabddcf11 | ||
|
a04fc7cdaf | ||
|
c7c6e642bd | ||
|
65c5d29ff3 | ||
|
fd40913fa5 | ||
|
e9418a7e64 | ||
|
7454cd99ab |
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 36 KiB |
12
src/App.vue
@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref } from "vue";
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NConfigProvider, NDropdown } from 'naive-ui'
|
||||
const { locale } = useI18n()
|
||||
const primaryColor = ref('#8B59F7')
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { NConfigProvider, NDropdown } from "naive-ui";
|
||||
const { locale } = useI18n();
|
||||
const primaryColor = ref("#ff7bac");
|
||||
const themeOverrides = ref({
|
||||
common: {
|
||||
primaryColorPressed: primaryColor,
|
||||
@ -15,7 +15,7 @@ const themeOverrides = ref({
|
||||
primaryColor: primaryColor,
|
||||
primaryColorHover: primaryColor,
|
||||
},
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
BIN
src/assets/image/1440/bg-contacts.png
Normal file
After Width: | Height: | Size: 126 KiB |
BIN
src/assets/image/1440/bg-events-calendar.png
Normal file
After Width: | Height: | Size: 177 KiB |
BIN
src/assets/image/1440/bg-news.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/image/1440/bg-pc.png
Normal file
After Width: | Height: | Size: 177 KiB |
BIN
src/assets/image/1440/bg-stock-quote.png
Normal file
After Width: | Height: | Size: 110 KiB |
BIN
src/assets/image/1440/contacts-bg.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
BIN
src/assets/image/1440/email-alerts-submit.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/image/1440/events-calendar-bg.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/image/1440/product-introduction-icon1.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/image/1440/product-introduction-icon2.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/image/1440/product-introduction-icon3.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/image/1440/product-introduction-img1.png
Normal file
After Width: | Height: | Size: 412 KiB |
BIN
src/assets/image/1440/product-introduction-img2.png
Normal file
After Width: | Height: | Size: 316 KiB |
BIN
src/assets/image/1440/product-introduction-img3.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
src/assets/image/1440/product-introduction-img4.png
Normal file
After Width: | Height: | Size: 2.4 MiB |
BIN
src/assets/image/1440/product-introduction-img5.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
src/assets/image/1440/product-introduction-img6.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/image/1920/bg-contacts.png
Normal file
After Width: | Height: | Size: 590 KiB |
BIN
src/assets/image/1920/bg-events-calendar.png
Normal file
After Width: | Height: | Size: 712 KiB |
BIN
src/assets/image/1920/bg-news.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/image/1920/bg-pc.png
Normal file
After Width: | Height: | Size: 712 KiB |
BIN
src/assets/image/1920/bg-stock-quote.png
Normal file
After Width: | Height: | Size: 296 KiB |
BIN
src/assets/image/1920/contacts-bg.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
BIN
src/assets/image/1920/email-alerts-submit.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/image/1920/events-calendar-bg.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/image/1920/product-introduction-icon1.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/image/1920/product-introduction-icon2.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/image/1920/product-introduction-icon3.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/image/1920/product-introduction-img1.png
Normal file
After Width: | Height: | Size: 412 KiB |
BIN
src/assets/image/1920/product-introduction-img2.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
src/assets/image/1920/product-introduction-img3.png
Normal file
After Width: | Height: | Size: 66 KiB |
BIN
src/assets/image/1920/product-introduction-img4.png
Normal file
After Width: | Height: | Size: 8.0 MiB |
BIN
src/assets/image/1920/product-introduction-img5.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
src/assets/image/1920/product-introduction-img6.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/image/375/bg-contacts.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
src/assets/image/375/bg-events-calendar.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/image/375/bg-news.png
Normal file
After Width: | Height: | Size: 9.7 KiB |
BIN
src/assets/image/375/bg-pc.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/image/375/bg-stock-quote.png
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
src/assets/image/375/contacts-bg.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
src/assets/image/375/email-alerts-submit.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/image/375/events-calendar-bg.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/image/375/menu-close.png
Normal file
After Width: | Height: | Size: 851 B |
BIN
src/assets/image/375/menu-open.png
Normal file
After Width: | Height: | Size: 640 B |
BIN
src/assets/image/375/product-introduction-icon1.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/image/375/product-introduction-icon2.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/image/375/product-introduction-icon3.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/image/375/product-introduction-img1.png
Normal file
After Width: | Height: | Size: 145 KiB |
BIN
src/assets/image/375/product-introduction-img2.png
Normal file
After Width: | Height: | Size: 136 KiB |
BIN
src/assets/image/375/product-introduction-img3.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/image/375/product-introduction-img4.png
Normal file
After Width: | Height: | Size: 858 KiB |
BIN
src/assets/image/375/product-introduction-img5.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/image/375/product-introduction-img6.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/image/768/bg-contacts.png
Normal file
After Width: | Height: | Size: 122 KiB |
BIN
src/assets/image/768/bg-events-calendar.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
src/assets/image/768/bg-news.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/image/768/bg-pc.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
src/assets/image/768/bg-stock-quote.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
src/assets/image/768/contacts-bg.png
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/image/768/email-alerts-submit.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
src/assets/image/768/events-calendar-bg.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/image/768/menu-close.png
Normal file
After Width: | Height: | Size: 851 B |
BIN
src/assets/image/768/menu-open.png
Normal file
After Width: | Height: | Size: 640 B |
BIN
src/assets/image/768/product-introduction-icon1.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/image/768/product-introduction-icon2.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/image/768/product-introduction-icon3.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
src/assets/image/768/product-introduction-img1.png
Normal file
After Width: | Height: | Size: 422 KiB |
BIN
src/assets/image/768/product-introduction-img2.png
Normal file
After Width: | Height: | Size: 176 KiB |
BIN
src/assets/image/768/product-introduction-img3.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/image/768/product-introduction-img4.png
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
src/assets/image/768/product-introduction-img5.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
src/assets/image/768/product-introduction-img6.png
Normal file
After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 134 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 134 KiB |
Before Width: | Height: | Size: 104 KiB |
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 874 KiB |
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 36 KiB |
4
src/assets/image/icon-link-svg.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="23" height="22" viewBox="0 0 23 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.6548 8.9774C13.516 8.84467 13.3681 8.72503 13.2156 8.61409L13.2153 8.61433C13.0584 8.46998 12.8453 8.38107 12.6104 8.38107C12.1283 8.38107 11.7375 8.75491 11.7375 9.21604C11.7375 9.42009 11.8142 9.60698 11.9413 9.75202C11.9413 9.75204 11.9413 9.75208 11.9414 9.75211C12.0024 9.82178 12.0751 9.88184 12.1567 9.92939C12.239 9.99432 12.3198 10.0582 12.3953 10.1305L12.4673 10.1994C13.372 11.0637 13.1019 12.5528 12.1972 13.4182L8.33659 17.1098C7.4319 17.9741 5.96108 17.9741 5.05634 17.1098L4.9838 17.0404C4.07904 16.175 4.07904 14.7671 4.9838 13.9039L6.68938 12.273C6.90822 12.1068 7.0487 11.8504 7.0487 11.5624C7.0487 11.0616 6.62422 10.6556 6.10059 10.6556C5.903 10.6556 5.71958 10.7134 5.5677 10.8123C5.56722 10.8114 5.56671 10.8104 5.56621 10.8095L5.54802 10.8257C5.48089 10.8719 5.42019 10.926 5.36776 10.9871L3.59529 12.5735C1.92917 14.1683 1.92917 16.7766 3.59529 18.3691L3.66726 18.4379C5.33337 20.0305 8.05903 20.0305 9.72514 18.4379L13.5846 14.7452C15.2484 13.1516 15.3895 10.6377 13.7257 9.04513L13.6548 8.9774Z" fill="#FF7BAC"/>
|
||||
<path d="M19.2173 3.56219L19.1454 3.49335C17.4793 1.89968 14.7536 1.89968 13.0875 3.49335L9.22805 7.18608C7.56193 8.77976 7.47018 11.0811 9.1363 12.6758L9.20711 12.7425C9.28278 12.8149 9.36132 12.8831 9.44149 12.9485C9.49952 13.0105 9.56769 13.0636 9.64347 13.1056C9.64405 13.106 9.64465 13.1065 9.64522 13.1069L9.64547 13.1067C9.76626 13.1731 9.90621 13.2113 10.0556 13.2113C10.5107 13.2113 10.8796 12.8584 10.8796 12.4231C10.8796 12.3002 10.8502 12.1839 10.7978 12.0802C10.6888 11.8462 10.4849 11.7038 10.3438 11.5689L10.273 11.5022C9.3683 10.6368 9.71185 9.37962 10.6165 8.51426L14.4783 4.82259C15.3807 3.9572 16.8521 3.9572 17.7568 4.82259L17.8288 4.89033C18.7335 5.75575 18.7335 7.1642 17.8288 8.0285L16.1288 9.65569C15.8975 9.81959 15.7476 10.0825 15.7476 10.3789C15.7476 10.8757 16.1686 11.2785 16.688 11.2785C16.8688 11.2785 17.0376 11.2296 17.1809 11.145C17.182 11.1468 17.183 11.1484 17.1841 11.1502L17.2104 11.1269C17.2917 11.0749 17.3638 11.011 17.4245 10.938L19.2161 9.35669C20.8834 7.76303 20.8835 5.15584 19.2173 3.56219Z" fill="#FF7BAC"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
397
src/components/DateWheelPicker.vue
Normal file
@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div class="picker-mask" @click.self="emit('close')">
|
||||
<div class="picker-panel">
|
||||
<div class="picker-title">
|
||||
Select Time
|
||||
<svg
|
||||
@click="emit('close')"
|
||||
style="cursor: pointer"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.666016 9.74935C0.666016 4.59469 4.84469 0.416016 9.99935 0.416016C15.154 0.416016 19.3327 4.59469 19.3327 9.74935C19.3327 14.904 15.154 19.0827 9.99935 19.0827C4.84469 19.0827 0.666016 14.904 0.666016 9.74935Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
<path
|
||||
d="M12.833 5.84961C13.1273 5.55596 13.6042 5.55565 13.8965 5.84863C14.1907 6.14223 14.1893 6.61848 13.8965 6.91211L11.0615 9.74609L13.9043 12.5898C14.1973 12.8848 14.1986 13.3607 13.9043 13.6543C13.6114 13.947 13.1344 13.9471 12.8408 13.6543L9.99707 10.8105L7.1582 13.6504C6.86386 13.9444 6.38729 13.9446 6.09375 13.6504C5.8002 13.3574 5.80045 12.8809 6.09473 12.5859L8.93359 9.74707L6.10254 6.91602C5.80956 6.62236 5.80889 6.1452 6.10254 5.85156C6.39486 5.55817 6.87209 5.55802 7.16699 5.85156L9.99805 8.68262L12.833 5.84961Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="picker-columns"
|
||||
:style="{ height: wheelViewportHeight + 'px' }"
|
||||
>
|
||||
<div class="center-lines" :style="{ height: wheelItemHeight + 'px' }">
|
||||
<div class="line"></div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div
|
||||
class="picker-col year-col"
|
||||
ref="yearColRef"
|
||||
@scroll.passive="(e) => handleWheelScroll('year', e)"
|
||||
@wheel.prevent="(e) => handleWheelStep('year', e)"
|
||||
:style="{
|
||||
scrollPaddingTop: wheelCenterPad + 'px',
|
||||
scrollPaddingBottom: wheelCenterPad + 'px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
<div
|
||||
v-for="y in years"
|
||||
:key="'y' + y"
|
||||
class="picker-item"
|
||||
:class="{ active: y === localYear }"
|
||||
:style="{
|
||||
height: wheelItemHeight + 'px',
|
||||
}"
|
||||
@click="localYear = y"
|
||||
>
|
||||
{{ y }}
|
||||
</div>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
</div>
|
||||
<div
|
||||
class="picker-col month-col"
|
||||
ref="monthColRef"
|
||||
@scroll.passive="(e) => handleWheelScroll('month', e)"
|
||||
@wheel.prevent="(e) => handleWheelStep('month', e)"
|
||||
:style="{
|
||||
scrollPaddingTop: wheelCenterPad + 'px',
|
||||
scrollPaddingBottom: wheelCenterPad + 'px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
<div
|
||||
v-for="m in months"
|
||||
:key="'m' + m"
|
||||
class="picker-item"
|
||||
:class="{ active: m === localMonth }"
|
||||
:style="{
|
||||
height: wheelItemHeight + 'px',
|
||||
}"
|
||||
@click="localMonth = m"
|
||||
>
|
||||
{{ m }}
|
||||
</div>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
</div>
|
||||
<div
|
||||
class="picker-col day-col"
|
||||
ref="dayColRef"
|
||||
@scroll.passive="(e) => handleWheelScroll('day', e)"
|
||||
@wheel.prevent="(e) => handleWheelStep('day', e)"
|
||||
:style="{
|
||||
scrollPaddingTop: wheelCenterPad + 'px',
|
||||
scrollPaddingBottom: wheelCenterPad + 'px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
<div
|
||||
v-for="d in daysInMonth(localYear, localMonth)"
|
||||
:key="'d' + d"
|
||||
class="picker-item"
|
||||
:class="{ active: d === localDay }"
|
||||
:style="{
|
||||
height: wheelItemHeight + 'px',
|
||||
}"
|
||||
@click="localDay = d"
|
||||
>
|
||||
{{ d }}
|
||||
</div>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-actions">
|
||||
<button class="picker-confirm" @click="confirm">
|
||||
Confirm Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true }, // { year, month, day }
|
||||
minYear: { type: Number, default: 2009 },
|
||||
maxYear: { type: Number, default: new Date().getFullYear() },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "close", "confirm"]);
|
||||
|
||||
const wheelItemBase = 8 * 5.12;
|
||||
const wheelItemHeight = Math.round(wheelItemBase); // use integer px to avoid fractional rounding
|
||||
const wheelViewportHeight = wheelItemHeight * 5;
|
||||
const wheelCenterPad = (wheelViewportHeight - wheelItemHeight) / 2;
|
||||
const isUserScrolling = ref(false);
|
||||
|
||||
const years = computed(() =>
|
||||
Array.from(
|
||||
{ length: props.maxYear - props.minYear + 1 },
|
||||
(_, i) => props.minYear + i
|
||||
)
|
||||
);
|
||||
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
const localYear = ref(props.modelValue.year);
|
||||
const localMonth = ref(props.modelValue.month);
|
||||
const localDay = ref(props.modelValue.day);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
localYear.value = v.year;
|
||||
localMonth.value = v.month;
|
||||
localDay.value = v.day;
|
||||
nextTick(syncWheelPositions);
|
||||
}
|
||||
);
|
||||
|
||||
const yearColRef = ref(null);
|
||||
const monthColRef = ref(null);
|
||||
const dayColRef = ref(null);
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
watch([localYear, localMonth], () => {
|
||||
const dim = daysInMonth(localYear.value, localMonth.value);
|
||||
if (localDay.value > dim) localDay.value = dim;
|
||||
if (!isUserScrolling.value) {
|
||||
nextTick(syncWheelPositions);
|
||||
}
|
||||
});
|
||||
|
||||
function syncWheelPositions() {
|
||||
const yearIdx = years.value.indexOf(localYear.value);
|
||||
const monthIdx = localMonth.value - 1;
|
||||
const dayIdx = localDay.value - 1;
|
||||
if (yearColRef.value) yearColRef.value.scrollTop = yearIdx * wheelItemHeight;
|
||||
if (monthColRef.value)
|
||||
monthColRef.value.scrollTop = monthIdx * wheelItemHeight;
|
||||
if (dayColRef.value) dayColRef.value.scrollTop = dayIdx * wheelItemHeight;
|
||||
}
|
||||
|
||||
const scrollTimers = { year: null, month: null, day: null };
|
||||
const isAnimating = { year: false, month: false, day: false };
|
||||
function getCenteredIndex(el) {
|
||||
if (!el) return 0;
|
||||
const raw = (el.scrollTop - wheelCenterPad) / wheelItemHeight;
|
||||
return Math.round(raw);
|
||||
}
|
||||
function clamp(n, min, max) {
|
||||
return Math.max(min, Math.min(n, max));
|
||||
}
|
||||
function getCenteredIndexByRect(el) {
|
||||
if (!el) return 0;
|
||||
const items = el.querySelectorAll(".picker-item");
|
||||
if (!items || items.length === 0) return 0;
|
||||
const containerRect = el.getBoundingClientRect();
|
||||
const centerY = containerRect.top + containerRect.height / 2;
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const r = items[i].getBoundingClientRect();
|
||||
const mid = r.top + r.height / 2;
|
||||
const dist = Math.abs(mid - centerY);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
return nearestIdx;
|
||||
}
|
||||
function handleWheelStep(type, e) {
|
||||
const refMap = { year: yearColRef, month: monthColRef, day: dayColRef };
|
||||
const el = refMap[type].value;
|
||||
if (!el || isAnimating[type]) return;
|
||||
const direction = e.deltaY > 0 ? 1 : -1;
|
||||
let idx = getCenteredIndexByRect(el);
|
||||
if (type === "year") {
|
||||
idx = clamp(idx + direction, 0, years.value.length - 1);
|
||||
localYear.value = years.value[idx];
|
||||
} else if (type === "month") {
|
||||
idx = clamp(idx + direction, 0, 11);
|
||||
localMonth.value = idx + 1;
|
||||
} else {
|
||||
const dim = daysInMonth(localYear.value, localMonth.value);
|
||||
idx = clamp(idx + direction, 0, dim - 1);
|
||||
localDay.value = idx + 1;
|
||||
}
|
||||
isAnimating[type] = true;
|
||||
isUserScrolling.value = true;
|
||||
el.scrollTo({ top: idx * wheelItemHeight, behavior: "smooth" });
|
||||
setTimeout(() => {
|
||||
isAnimating[type] = false;
|
||||
isUserScrolling.value = false;
|
||||
}, 180);
|
||||
}
|
||||
function handleWheelScroll(type, e) {
|
||||
clearTimeout(scrollTimers[type]);
|
||||
isUserScrolling.value = true;
|
||||
scrollTimers[type] = setTimeout(() => {
|
||||
const el = e.target;
|
||||
const idx = getCenteredIndexByRect(el);
|
||||
if (type === "year") {
|
||||
localYear.value = years.value[clamp(idx, 0, years.value.length - 1)];
|
||||
} else if (type === "month") {
|
||||
localMonth.value = clamp(idx + 1, 1, 12);
|
||||
} else if (type === "day") {
|
||||
const dim = daysInMonth(localYear.value, localMonth.value);
|
||||
localDay.value = clamp(idx + 1, 1, dim);
|
||||
}
|
||||
isUserScrolling.value = false;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
// 以滚轮当前“水平线”居中项为最终值(基于 DOM 位置,避免像素取整误差)
|
||||
if (yearColRef.value) {
|
||||
const idx = clamp(
|
||||
getCenteredIndexByRect(yearColRef.value),
|
||||
0,
|
||||
years.value.length - 1
|
||||
);
|
||||
localYear.value = years.value[idx];
|
||||
}
|
||||
if (monthColRef.value) {
|
||||
const idx = clamp(getCenteredIndexByRect(monthColRef.value) + 1, 1, 12);
|
||||
localMonth.value = idx;
|
||||
}
|
||||
if (dayColRef.value) {
|
||||
const dim = daysInMonth(localYear.value, localMonth.value);
|
||||
const idx = clamp(getCenteredIndexByRect(dayColRef.value) + 1, 1, dim);
|
||||
localDay.value = idx;
|
||||
}
|
||||
// 输出固定为本地日期,不跨时区
|
||||
const payload = {
|
||||
year: localYear.value,
|
||||
month: localMonth.value,
|
||||
day: localDay.value,
|
||||
};
|
||||
emit("update:modelValue", payload);
|
||||
nextTick(() => emit("confirm", payload));
|
||||
}
|
||||
|
||||
nextTick(syncWheelPositions);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.picker-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.picker-panel {
|
||||
width: 311 * 5.12px;
|
||||
background: #fff;
|
||||
border-radius: 8 * 5.12px;
|
||||
box-shadow: 0 3 * 5.12px 14 * 5.12px rgba(0, 0, 0, 0.16);
|
||||
padding: 16 * 5.12px;
|
||||
}
|
||||
.picker-title {
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14 * 5.12px;
|
||||
color: #455363;
|
||||
margin-bottom: 16 * 5.12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.picker-columns {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
column-gap: 32 * 5.12px;
|
||||
align-items: stretch;
|
||||
justify-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
.center-lines {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 0;
|
||||
}
|
||||
.center-lines .line {
|
||||
position: absolute;
|
||||
left: 16 * 5.12px; /* align with design padding */
|
||||
right: 16 * 5.12px;
|
||||
height: 1px; /* crisp hairline */
|
||||
background: #ededed;
|
||||
}
|
||||
.center-lines .line:first-child {
|
||||
top: 0;
|
||||
}
|
||||
.center-lines .line:last-child {
|
||||
bottom: 0;
|
||||
}
|
||||
.picker-col {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
z-index: 1; /* paint above guide lines */
|
||||
scroll-snap-type: y mandatory;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.picker-col::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.picker-item {
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14 * 5.12px;
|
||||
color: #9da3ad;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
.year-col .picker-item {
|
||||
justify-content: flex-start;
|
||||
padding-left: 16 * 5.12px;
|
||||
}
|
||||
.month-col .picker-item {
|
||||
justify-content: center;
|
||||
}
|
||||
.day-col .picker-item {
|
||||
justify-content: flex-end;
|
||||
padding-right: 16 * 5.12px;
|
||||
}
|
||||
.picker-item.active {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
.picker-actions {
|
||||
margin-top: 16 * 5.12px;
|
||||
}
|
||||
.picker-confirm {
|
||||
width: 100%;
|
||||
height: 44 * 5.12px;
|
||||
background: #ff7bac;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8 * 5.12px;
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14 * 5.12px;
|
||||
}
|
||||
</style>
|
339
src/components/YearMonthWheelPicker.vue
Normal file
@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div class="picker-mask" @click.self="emit('close')">
|
||||
<div class="picker-panel">
|
||||
<div class="picker-title">
|
||||
Select
|
||||
<svg
|
||||
@click="emit('close')"
|
||||
style="cursor: pointer"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.666016 9.74935C0.666016 4.59469 4.84469 0.416016 9.99935 0.416016C15.154 0.416016 19.3327 4.59469 19.3327 9.74935C19.3327 14.904 15.154 19.0827 9.99935 19.0827C4.84469 19.0827 0.666016 14.904 0.666016 9.74935Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
<path
|
||||
d="M12.833 5.84961C13.1273 5.55596 13.6042 5.55565 13.8965 5.84863C14.1907 6.14223 14.1893 6.61848 13.8965 6.91211L11.0615 9.74609L13.9043 12.5898C14.1973 12.8848 14.1986 13.3607 13.9043 13.6543C13.6114 13.947 13.1344 13.9471 12.8408 13.6543L9.99707 10.8105L7.1582 13.6504C6.86386 13.9444 6.38729 13.9446 6.09375 13.6504C5.8002 13.3574 5.80045 12.8809 6.09473 12.5859L8.93359 9.74707L6.10254 6.91602C5.80956 6.62236 5.80889 6.1452 6.10254 5.85156C6.39486 5.55817 6.87209 5.55802 7.16699 5.85156L9.99805 8.68262L12.833 5.84961Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="picker-columns"
|
||||
:style="{ height: wheelViewportHeight + 'px' }"
|
||||
>
|
||||
<div class="center-lines" :style="{ height: wheelItemHeight + 'px' }">
|
||||
<div class="line"></div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div
|
||||
class="picker-col year-col"
|
||||
ref="yearColRef"
|
||||
@scroll.passive="(e) => handleWheelScroll('year', e)"
|
||||
@wheel.prevent="(e) => handleWheelStep('year', e)"
|
||||
:style="{
|
||||
scrollPaddingTop: wheelCenterPad + 'px',
|
||||
scrollPaddingBottom: wheelCenterPad + 'px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
<div
|
||||
v-for="y in years"
|
||||
:key="'y' + y"
|
||||
class="picker-item"
|
||||
:class="{ active: y === localYear }"
|
||||
:style="{
|
||||
height: wheelItemHeight + 'px',
|
||||
}"
|
||||
@click="localYear = y"
|
||||
>
|
||||
{{ y }}
|
||||
</div>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
</div>
|
||||
<div
|
||||
class="picker-col month-col"
|
||||
ref="monthColRef"
|
||||
@scroll.passive="(e) => handleWheelScroll('month', e)"
|
||||
@wheel.prevent="(e) => handleWheelStep('month', e)"
|
||||
:style="{
|
||||
scrollPaddingTop: wheelCenterPad + 'px',
|
||||
scrollPaddingBottom: wheelCenterPad + 'px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
<div
|
||||
v-for="m in months"
|
||||
:key="'m' + m"
|
||||
class="picker-item"
|
||||
:class="{ active: m === localMonth }"
|
||||
:style="{
|
||||
height: wheelItemHeight + 'px',
|
||||
}"
|
||||
@click="localMonth = m"
|
||||
>
|
||||
{{ m }}
|
||||
</div>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-actions">
|
||||
<button class="picker-confirm" @click="confirm">
|
||||
Confirm Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true }, // { year, month }
|
||||
minYear: { type: Number, default: 2009 },
|
||||
maxYear: { type: Number, default: new Date().getFullYear() },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "close", "confirm"]);
|
||||
|
||||
const wheelItemBase = 8 * 5.12;
|
||||
const wheelItemHeight = Math.round(wheelItemBase); // use integer px to avoid fractional rounding
|
||||
const wheelViewportHeight = wheelItemHeight * 5;
|
||||
const wheelCenterPad = (wheelViewportHeight - wheelItemHeight) / 2;
|
||||
const isUserScrolling = ref(false);
|
||||
|
||||
const years = computed(() =>
|
||||
Array.from(
|
||||
{ length: props.maxYear - props.minYear + 1 },
|
||||
(_, i) => props.minYear + i
|
||||
)
|
||||
);
|
||||
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
const localYear = ref(props.modelValue.year);
|
||||
const localMonth = ref(props.modelValue.month);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
localYear.value = v.year;
|
||||
localMonth.value = v.month;
|
||||
nextTick(syncWheelPositions);
|
||||
}
|
||||
);
|
||||
|
||||
const yearColRef = ref(null);
|
||||
const monthColRef = ref(null);
|
||||
|
||||
watch([localYear, localMonth], () => {
|
||||
if (!isUserScrolling.value) {
|
||||
nextTick(syncWheelPositions);
|
||||
}
|
||||
});
|
||||
|
||||
function syncWheelPositions() {
|
||||
const yearIdx = years.value.indexOf(localYear.value);
|
||||
const monthIdx = localMonth.value - 1;
|
||||
if (yearColRef.value) yearColRef.value.scrollTop = yearIdx * wheelItemHeight;
|
||||
if (monthColRef.value)
|
||||
monthColRef.value.scrollTop = monthIdx * wheelItemHeight;
|
||||
}
|
||||
|
||||
const scrollTimers = { year: null, month: null };
|
||||
const isAnimating = { year: false, month: false };
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.max(min, Math.min(n, max));
|
||||
}
|
||||
function getCenteredIndexByRect(el) {
|
||||
if (!el) return 0;
|
||||
const items = el.querySelectorAll(".picker-item");
|
||||
if (!items || items.length === 0) return 0;
|
||||
const containerRect = el.getBoundingClientRect();
|
||||
const centerY = containerRect.top + containerRect.height / 2;
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const r = items[i].getBoundingClientRect();
|
||||
const mid = r.top + r.height / 2;
|
||||
const dist = Math.abs(mid - centerY);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
return nearestIdx;
|
||||
}
|
||||
function handleWheelStep(type, e) {
|
||||
const refMap = { year: yearColRef, month: monthColRef };
|
||||
const el = refMap[type].value;
|
||||
if (!el || isAnimating[type]) return;
|
||||
const direction = e.deltaY > 0 ? 1 : -1;
|
||||
let idx = getCenteredIndexByRect(el);
|
||||
if (type === "year") {
|
||||
idx = clamp(idx + direction, 0, years.value.length - 1);
|
||||
localYear.value = years.value[idx];
|
||||
} else if (type === "month") {
|
||||
idx = clamp(idx + direction, 0, 11);
|
||||
localMonth.value = idx + 1;
|
||||
}
|
||||
isAnimating[type] = true;
|
||||
isUserScrolling.value = true;
|
||||
el.scrollTo({ top: idx * wheelItemHeight, behavior: "smooth" });
|
||||
setTimeout(() => {
|
||||
isAnimating[type] = false;
|
||||
isUserScrolling.value = false;
|
||||
}, 180);
|
||||
}
|
||||
function handleWheelScroll(type, e) {
|
||||
clearTimeout(scrollTimers[type]);
|
||||
isUserScrolling.value = true;
|
||||
scrollTimers[type] = setTimeout(() => {
|
||||
const el = e.target;
|
||||
const idx = getCenteredIndexByRect(el);
|
||||
if (type === "year") {
|
||||
localYear.value = years.value[clamp(idx, 0, years.value.length - 1)];
|
||||
} else if (type === "month") {
|
||||
localMonth.value = clamp(idx + 1, 1, 12);
|
||||
}
|
||||
isUserScrolling.value = false;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (yearColRef.value) {
|
||||
const idx = clamp(
|
||||
getCenteredIndexByRect(yearColRef.value),
|
||||
0,
|
||||
years.value.length - 1
|
||||
);
|
||||
localYear.value = years.value[idx];
|
||||
}
|
||||
if (monthColRef.value) {
|
||||
const idx = clamp(getCenteredIndexByRect(monthColRef.value) + 1, 1, 12);
|
||||
localMonth.value = idx;
|
||||
}
|
||||
const payload = {
|
||||
year: localYear.value,
|
||||
month: localMonth.value,
|
||||
};
|
||||
emit("update:modelValue", payload);
|
||||
nextTick(() => emit("confirm", payload));
|
||||
}
|
||||
|
||||
nextTick(syncWheelPositions);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.picker-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.picker-panel {
|
||||
width: 311 * 5.12px;
|
||||
background: #fff;
|
||||
border-radius: 8 * 5.12px;
|
||||
box-shadow: 0 3 * 5.12px 14 * 5.12px rgba(0, 0, 0, 0.16);
|
||||
padding: 16 * 5.12px;
|
||||
}
|
||||
.picker-title {
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14 * 5.12px;
|
||||
color: #455363;
|
||||
margin-bottom: 16 * 5.12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.picker-columns {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 32 * 5.12px;
|
||||
align-items: stretch;
|
||||
justify-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
.center-lines {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 0;
|
||||
}
|
||||
.center-lines .line {
|
||||
position: absolute;
|
||||
left: 16 * 5.12px;
|
||||
right: 16 * 5.12px;
|
||||
height: 1 * 5.12px;
|
||||
background: #ededed;
|
||||
}
|
||||
.center-lines .line:first-child {
|
||||
top: 0;
|
||||
}
|
||||
.center-lines .line:last-child {
|
||||
bottom: 0;
|
||||
}
|
||||
.picker-col {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
scroll-snap-type: y mandatory;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.picker-col::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.picker-item {
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14 * 5.12px;
|
||||
color: #9da3ad;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
.year-col .picker-item {
|
||||
justify-content: flex-start;
|
||||
padding-left: 16 * 5.12px;
|
||||
}
|
||||
.month-col .picker-item {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picker-item.active {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
.picker-actions {
|
||||
margin-top: 16 * 5.12px;
|
||||
}
|
||||
.picker-confirm {
|
||||
width: 100%;
|
||||
height: 44 * 5.12px;
|
||||
background: #ff7bac;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8 * 5.12px;
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14 * 5.12px;
|
||||
}
|
||||
</style>
|
286
src/components/YearWheelPicker.vue
Normal file
@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="picker-mask" @click.self="emit('close')">
|
||||
<div class="picker-panel">
|
||||
<div class="picker-title">
|
||||
Select
|
||||
<svg
|
||||
@click="emit('close')"
|
||||
style="cursor: pointer"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.666016 9.74935C0.666016 4.59469 4.84469 0.416016 9.99935 0.416016C15.154 0.416016 19.3327 4.59469 19.3327 9.74935C19.3327 14.904 15.154 19.0827 9.99935 19.0827C4.84469 19.0827 0.666016 14.904 0.666016 9.74935Z"
|
||||
fill="#CCCCCC"
|
||||
/>
|
||||
<path
|
||||
d="M12.833 5.84961C13.1273 5.55596 13.6042 5.55565 13.8965 5.84863C14.1907 6.14223 14.1893 6.61848 13.8965 6.91211L11.0615 9.74609L13.9043 12.5898C14.1973 12.8848 14.1986 13.3607 13.9043 13.6543C13.6114 13.947 13.1344 13.9471 12.8408 13.6543L9.99707 10.8105L7.1582 13.6504C6.86386 13.9444 6.38729 13.9446 6.09375 13.6504C5.8002 13.3574 5.80045 12.8809 6.09473 12.5859L8.93359 9.74707L6.10254 6.91602C5.80956 6.62236 5.80889 6.1452 6.10254 5.85156C6.39486 5.55817 6.87209 5.55802 7.16699 5.85156L9.99805 8.68262L12.833 5.84961Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="picker-columns"
|
||||
:style="{ height: wheelViewportHeight + 'px' }"
|
||||
>
|
||||
<div class="center-lines" :style="{ height: wheelItemHeight + 'px' }">
|
||||
<div class="line"></div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div
|
||||
class="picker-col year-col"
|
||||
ref="yearColRef"
|
||||
@scroll.passive="handleWheelScroll"
|
||||
@wheel.prevent="handleWheelStep"
|
||||
:style="{
|
||||
scrollPaddingTop: wheelCenterPad + 'px',
|
||||
scrollPaddingBottom: wheelCenterPad + 'px',
|
||||
}"
|
||||
>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
<div
|
||||
v-for="opt in props.options"
|
||||
:key="opt.value"
|
||||
class="picker-item"
|
||||
:class="{ active: opt.value === localYear }"
|
||||
:style="{ height: wheelItemHeight + 'px' }"
|
||||
@click="localYear = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
<div :style="{ height: wheelCenterPad + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="picker-actions">
|
||||
<button class="picker-confirm" @click="confirm">
|
||||
Confirm Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, Teleport } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], required: true },
|
||||
options: { type: Array, required: true },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "close", "confirm"]);
|
||||
|
||||
const wheelItemBase = 8 * 5.12;
|
||||
const wheelItemHeight = Math.round(wheelItemBase);
|
||||
const wheelViewportHeight = wheelItemHeight * 5;
|
||||
const wheelCenterPad = (wheelViewportHeight - wheelItemHeight) / 2;
|
||||
|
||||
const localYear = ref(props.modelValue);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
localYear.value = v;
|
||||
nextTick(syncWheelPositions);
|
||||
}
|
||||
);
|
||||
|
||||
const yearColRef = ref(null);
|
||||
|
||||
watch(localYear, () => {
|
||||
if (!isUserScrolling.value) {
|
||||
nextTick(syncWheelPositions);
|
||||
}
|
||||
});
|
||||
|
||||
function syncWheelPositions() {
|
||||
const yearIdx = props.options.findIndex(
|
||||
(opt) => opt.value === localYear.value
|
||||
);
|
||||
if (yearColRef.value) {
|
||||
yearColRef.value.scrollTop = yearIdx * wheelItemHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollTimer = ref(null);
|
||||
const isAnimating = ref(false);
|
||||
const isUserScrolling = ref(false);
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.max(min, Math.min(n, max));
|
||||
}
|
||||
|
||||
function getCenteredIndexByRect(el) {
|
||||
if (!el) return 0;
|
||||
const items = el.querySelectorAll(".picker-item");
|
||||
if (!items || items.length === 0) return 0;
|
||||
const containerRect = el.getBoundingClientRect();
|
||||
const centerY = containerRect.top + containerRect.height / 2;
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const r = items[i].getBoundingClientRect();
|
||||
const mid = r.top + r.height / 2;
|
||||
const dist = Math.abs(mid - centerY);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
return nearestIdx;
|
||||
}
|
||||
|
||||
function handleWheelStep(e) {
|
||||
const el = yearColRef.value;
|
||||
if (!el || isAnimating.value) return;
|
||||
const direction = e.deltaY > 0 ? 1 : -1;
|
||||
let idx = getCenteredIndexByRect(el);
|
||||
const options = props.options;
|
||||
|
||||
idx = clamp(idx + direction, 0, options.length - 1);
|
||||
localYear.value = options[idx].value;
|
||||
|
||||
isAnimating.value = true;
|
||||
isUserScrolling.value = true;
|
||||
el.scrollTo({ top: idx * wheelItemHeight, behavior: "smooth" });
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false;
|
||||
isUserScrolling.value = false;
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function handleWheelScroll(e) {
|
||||
clearTimeout(scrollTimer.value);
|
||||
isUserScrolling.value = true;
|
||||
scrollTimer.value = setTimeout(() => {
|
||||
const el = e.target;
|
||||
const idx = getCenteredIndexByRect(el);
|
||||
const options = props.options;
|
||||
localYear.value = options[clamp(idx, 0, options.length - 1)].value;
|
||||
isUserScrolling.value = false;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (yearColRef.value) {
|
||||
const idx = getCenteredIndexByRect(yearColRef.value);
|
||||
const options = props.options;
|
||||
if (options[idx]) {
|
||||
localYear.value = options[idx].value;
|
||||
}
|
||||
}
|
||||
emit("update:modelValue", localYear.value);
|
||||
nextTick(() => emit("confirm", localYear.value));
|
||||
}
|
||||
|
||||
nextTick(syncWheelPositions);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.picker-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.picker-panel {
|
||||
width: 311 * 5.12px;
|
||||
background: #fff;
|
||||
border-radius: 8 * 5.12px;
|
||||
box-shadow: 0 3 * 5.12px 14 * 5.12px rgba(0, 0, 0, 0.16);
|
||||
padding: 16 * 5.12px;
|
||||
}
|
||||
.picker-title {
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14 * 5.12px;
|
||||
color: #455363;
|
||||
margin-bottom: 16 * 5.12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.picker-columns {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
justify-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
.center-lines {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 0;
|
||||
}
|
||||
.center-lines .line {
|
||||
position: absolute;
|
||||
left: 16 * 5.12px;
|
||||
right: 16 * 5.12px;
|
||||
height: 1 * 5.12px;
|
||||
background: #ededed;
|
||||
}
|
||||
.center-lines .line:first-child {
|
||||
top: 0;
|
||||
}
|
||||
.center-lines .line:last-child {
|
||||
bottom: 0;
|
||||
}
|
||||
.picker-col {
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
scroll-snap-type: y mandatory;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.picker-col::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.picker-item {
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14 * 5.12px;
|
||||
color: #9da3ad;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
.year-col .picker-item {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picker-item.active {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
.picker-actions {
|
||||
margin-top: 16 * 5.12px;
|
||||
}
|
||||
.picker-confirm {
|
||||
width: 100%;
|
||||
height: 44 * 5.12px;
|
||||
background: #ff7bac;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8 * 5.12px;
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14 * 5.12px;
|
||||
}
|
||||
</style>
|
@ -1,30 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { computed } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
|
||||
import size375 from '@/components/customEcharts/size375/index.vue'
|
||||
import size768 from '@/components/customEcharts/size375/index.vue'
|
||||
import size1440 from '@/components/customEcharts/size1920/index.vue'
|
||||
import size1920 from '@/components/customEcharts/size1920/index.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import size375 from "@/components/customEcharts/size375/index.vue";
|
||||
import size768 from "@/components/customEcharts/size768/index.vue";
|
||||
import size1440 from "@/components/customEcharts/size1440/index.vue";
|
||||
import size1920 from "@/components/customEcharts/size1920/index.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const router = useRouter()
|
||||
const { width } = useWindowSize()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
const { width } = useWindowSize();
|
||||
const { t } = useI18n();
|
||||
|
||||
const viewComponent = computed(() => {
|
||||
const viewWidth = width.value
|
||||
if (viewWidth <= 500) {
|
||||
return size375
|
||||
} else if (viewWidth <= 960) {
|
||||
return size768
|
||||
const viewWidth = width.value;
|
||||
if (viewWidth <= 450) {
|
||||
return size375;
|
||||
} else if (viewWidth <= 1100) {
|
||||
return size768;
|
||||
} else if (viewWidth <= 1500) {
|
||||
return size1440
|
||||
return size1440;
|
||||
} else if (viewWidth <= 1920 || viewWidth > 1920) {
|
||||
return size1920
|
||||
return size1920;
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
673
src/components/customEcharts/size1440/index.vue
Normal file
@ -0,0 +1,673 @@
|
||||
<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>
|
@ -2,42 +2,36 @@
|
||||
<div class="custom-echarts">
|
||||
<div>
|
||||
<div class="echarts-header">
|
||||
<div class="echarts-header-title">
|
||||
<span>FiEE, Inc. Stock Price History</span>
|
||||
<!-- 标题区域 -->
|
||||
<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;">
|
||||
Range
|
||||
<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 state.searchRange"
|
||||
v-for="(item, index) in searchRangeOptions"
|
||||
:key="index"
|
||||
@click="changeSearchRange(item)"
|
||||
:class="{ activeRange: state.activeRange === item.key }"
|
||||
@click="changeSearchRange(item.key)"
|
||||
>
|
||||
<span>{{ item }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="echarts-search-byDate">
|
||||
<n-date-picker
|
||||
v-model:value="state.selectHistoricStartDate"
|
||||
type="date"
|
||||
:is-date-disabled="disableAfterDate"
|
||||
@update:value="changeSearchRangeStartDate"
|
||||
input-readonly
|
||||
/>
|
||||
<!-- <n-icon size="16">
|
||||
<ArrowForwardOutline />
|
||||
</n-icon> -->
|
||||
<span>to</span>
|
||||
<n-date-picker
|
||||
v-model:value="state.selectHistoricEndDate"
|
||||
type="date"
|
||||
:is-date-disabled="disablePreviousDate"
|
||||
@update:value="changeSearchRangeEndDate"
|
||||
v-model:value="state.dateRange"
|
||||
type="daterange"
|
||||
:is-date-disabled="isDateDisabled"
|
||||
@update:value="handleDateRangeChange"
|
||||
input-readonly
|
||||
/>
|
||||
</div>
|
||||
@ -48,40 +42,50 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, watch, reactive } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import markPointerIcon from '@/assets/image/icon/echarts_markPointer.png'
|
||||
import axios from 'axios'
|
||||
import { NDatePicker, NIcon } from 'naive-ui'
|
||||
import { ArrowForwardOutline } from '@vicons/ionicons5'
|
||||
|
||||
import { onMounted, onBeforeUnmount, watch, reactive, computed } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
import { NDatePicker, NIcon } from "naive-ui";
|
||||
import { ArrowForwardOutline } from "@vicons/ionicons5";
|
||||
import axios from "axios";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const { t, locale } = useI18n();
|
||||
const state = reactive({
|
||||
searchRange: ['1m', '3m', 'YTD', '1Y', '5Y', '10Y', 'Max'],
|
||||
selectHistoricStartDate: '2009-10-07',
|
||||
selectHistoricEndDate: new Date(),
|
||||
})
|
||||
searchRange: ["1m", "3m", "YTD", "1Y", "5Y", "10Y", "Max"],
|
||||
dateRange: [new Date("2009-10-07").getTime(), new Date().getTime()],
|
||||
activeRange: "",
|
||||
});
|
||||
|
||||
let myCharts = null
|
||||
let historicData = []
|
||||
let xAxisData = []
|
||||
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
|
||||
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)
|
||||
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 = echarts.init(document.getElementById("myEcharts"), null, {
|
||||
renderer: "canvas",
|
||||
useDirtyRect: true,
|
||||
});
|
||||
// 绘制图表
|
||||
myCharts.setOption({
|
||||
animation: false,
|
||||
@ -91,68 +95,72 @@ const initEcharts = (data) => {
|
||||
// text: 'FiEE, Inc. Stock Price History',
|
||||
// },
|
||||
grid: {
|
||||
left: '8%', // 或 '2%',根据实际情况调整
|
||||
right: '12%', // 给右侧y轴留空间,数值可根据y轴label宽度调整
|
||||
left: "8%", // 或 '2%',根据实际情况调整
|
||||
right: "12%", // 给右侧y轴留空间,数值可根据y轴label宽度调整
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
trigger: "axis",
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
type: "line",
|
||||
label: {
|
||||
backgroundColor: '#6a7985',
|
||||
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;">Price: ${p.data}</span>`
|
||||
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',
|
||||
triggerOn: "mousemove",
|
||||
confine: true,
|
||||
hideDelay: 1500
|
||||
hideDelay: 1500,
|
||||
},
|
||||
xAxis: {
|
||||
data: xAxisData,
|
||||
type: 'category',
|
||||
type: "category",
|
||||
boundaryGap: false,
|
||||
inverse: true,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#CCD6EB',
|
||||
color: "#CCD6EB",
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#323232',
|
||||
fontWeight: 'bold',
|
||||
interval: 'auto',
|
||||
hideOverlap: true
|
||||
color: "#323232",
|
||||
fontWeight: "bold",
|
||||
interval: "auto",
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
position: 'right',
|
||||
type: "value",
|
||||
position: "right",
|
||||
interval: 25,
|
||||
// max: 75.0,
|
||||
show: true,
|
||||
axisLabel: {
|
||||
color: '#323232',
|
||||
fontWeight: 'bold',
|
||||
color: "#323232",
|
||||
fontWeight: "bold",
|
||||
formatter: function (value) {
|
||||
return value > 0 ? value.toFixed(2) : value
|
||||
return value > 0 ? value.toFixed(2) : value;
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: yAxisData,
|
||||
type: 'line',
|
||||
sampling: 'lttb',
|
||||
symbol: 'none',
|
||||
type: "line",
|
||||
sampling: "lttb",
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
color: '#2c6288',
|
||||
color: "#CC346C",
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
type: "linear",
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
@ -160,387 +168,427 @@ const initEcharts = (data) => {
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: '#2c6288',
|
||||
color: "#CC346C",
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#F4F6F8',
|
||||
color: "#F4F6F8",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
markPoint: {
|
||||
symbol: 'image://' + markPointerIcon,
|
||||
symbolSize: 24,
|
||||
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
|
||||
largeThreshold: 2000,
|
||||
},
|
||||
],
|
||||
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
type: "inside",
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
type: "slider",
|
||||
show: true,
|
||||
dataBackground: {
|
||||
lineStyle: {
|
||||
color: '#2C6288',
|
||||
color: "#CC346C",
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
type: "linear",
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 1, color: '#2c6288' },
|
||||
{ offset: 0, color: '#F4F6F8' },
|
||||
{ offset: 1, color: "#CC346C" },
|
||||
{ offset: 0, color: "#F4F6F8" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
selectedDataBackground: {
|
||||
lineStyle: {
|
||||
color: '#2C6288',
|
||||
color: "#CC346C",
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
type: "linear",
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 1, color: '#2c6288' },
|
||||
{ offset: 0, color: '#F4F6F8' },
|
||||
{ offset: 1, color: "#CC346C" },
|
||||
{ offset: 0, color: "#F4F6F8" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
fillerColor: 'rgba(44, 98, 136, 0.3)',
|
||||
fillerColor: "rgba(44, 98, 136, 0.3)",
|
||||
realtime: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
// 监听 showTip 事件,动态显示 markPoint
|
||||
myCharts.on('showTip', function (params) {
|
||||
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]
|
||||
const dataIndex = params.dataIndex;
|
||||
const x = myCharts.getOption().xAxis[0].data[dataIndex];
|
||||
const y = myCharts.getOption().series[0].data[dataIndex];
|
||||
myCharts.setOption({
|
||||
series: [
|
||||
{
|
||||
markPoint: {
|
||||
symbol: 'image://' + markPointerIcon,
|
||||
symbolSize: 24,
|
||||
data: [{ coord: [x, y] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
// 鼠标移出时,清除 markPoint
|
||||
myCharts.on('globalout', function () {
|
||||
myCharts.on("globalout", function () {
|
||||
myCharts.setOption({
|
||||
series: [
|
||||
{
|
||||
markPoint: {
|
||||
symbol: 'image://' + markPointerIcon,
|
||||
symbolSize: 24,
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
myCharts.on('dataZoom', function (params) {
|
||||
});
|
||||
});
|
||||
myCharts.on("dataZoom", function (params) {
|
||||
// 获取当前 dataZoom 范围
|
||||
const option = myCharts.getOption()
|
||||
const xAxisData = option.xAxis[0].data
|
||||
const dataZoom = option.dataZoom[1] || option.dataZoom[0]
|
||||
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
|
||||
let startValue = dataZoom.endValue;
|
||||
let endValue = dataZoom.startValue;
|
||||
|
||||
// 如果是索引,转为日期
|
||||
if (typeof startValue === 'number') {
|
||||
startValue = xAxisData[startValue]
|
||||
if (typeof startValue === "number") {
|
||||
startValue = xAxisData[startValue];
|
||||
}
|
||||
if (typeof endValue === 'number') {
|
||||
endValue = xAxisData[endValue]
|
||||
if (typeof endValue === "number") {
|
||||
endValue = xAxisData[endValue];
|
||||
}
|
||||
|
||||
// 更新日期选择器
|
||||
state.selectHistoricStartDate = new Date(startValue)
|
||||
state.selectHistoricEndDate = new Date(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()
|
||||
})
|
||||
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 now = new Date();
|
||||
let toDate =
|
||||
now.getFullYear() +
|
||||
'-' +
|
||||
String(now.getMonth() + 1).padStart(2, '0') +
|
||||
'-' +
|
||||
String(now.getDate()).padStart(2, '0')
|
||||
"-" +
|
||||
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)
|
||||
"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)
|
||||
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 // 默认返回最后一个
|
||||
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()
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const midTime = new Date(data[mid].date).getTime();
|
||||
if (midTime > target) {
|
||||
left = mid + 1
|
||||
left = mid + 1;
|
||||
} else {
|
||||
res = mid
|
||||
right = mid - 1
|
||||
res = mid;
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
|
||||
// 适配倒序数据,返回小于等于目标日期的最近一天索引
|
||||
function findClosestDateIndexDescLeft(data, targetDateStr) {
|
||||
let left = 0,
|
||||
right = data.length - 1
|
||||
const target = new Date(targetDateStr).getTime()
|
||||
let res = -1
|
||||
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()
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const midTime = new Date(data[mid].date).getTime();
|
||||
if (midTime > target) {
|
||||
left = mid + 1 // mid 比目标新,往更旧的方向找
|
||||
left = mid + 1; // mid 比目标新,往更旧的方向找
|
||||
} else {
|
||||
res = mid // mid <= target,记录下来,继续往更新的方向找
|
||||
right = mid - 1
|
||||
res = mid; // mid <= target,记录下来,继续往更新的方向找
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
|
||||
//点击切换搜索区间
|
||||
const changeSearchRange = (range, dateTime) => {
|
||||
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
|
||||
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'
|
||||
typeof historicData !== "undefined" &&
|
||||
typeof xAxisData !== "undefined"
|
||||
) {
|
||||
let startValue = xAxisData[0]
|
||||
if (startDate) {
|
||||
const idx = findClosestDateIndex(historicData, startDate)
|
||||
// 用 historicData[idx].date 格式化为 xAxisData 的格式
|
||||
startValue = new Date(historicData[idx].date).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
)
|
||||
}
|
||||
let endValue = endDate
|
||||
if (endDate) {
|
||||
// console.warn(endDate)
|
||||
const idx = findClosestDateIndexDescLeft(historicData, endDate)
|
||||
// console.warn(idx)
|
||||
// 用 historicData[idx].date 格式化为 xAxisData 的格式
|
||||
endValue = new Date(historicData[idx].date).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
)
|
||||
// console.warn(endValue)
|
||||
}
|
||||
const zoomOptions = {};
|
||||
let newStartTs = state.dateRange[0];
|
||||
let newEndTs = state.dateRange[1];
|
||||
|
||||
if (startDate) {
|
||||
myCharts.setOption({
|
||||
dataZoom: {
|
||||
endValue: startValue,
|
||||
},
|
||||
})
|
||||
state.selectHistoricStartDate = new Date(startValue)
|
||||
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) {
|
||||
myCharts.setOption({
|
||||
dataZoom: {
|
||||
startValue: endValue,
|
||||
},
|
||||
})
|
||||
state.selectHistoricEndDate = new Date(endValue)
|
||||
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: '',
|
||||
startValue: "",
|
||||
endValue: "",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
state.selectHistoricStartDate = new Date('2009-10-07')
|
||||
state.selectHistoricEndDate = new Date()
|
||||
state.dateRange = [new Date("2009-10-07").getTime(), new Date().getTime()];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 禁用2009-10-07之后的日期
|
||||
const disableAfterDate = (date) => {
|
||||
return date < new Date('2009-10-06') || date > new Date()
|
||||
}
|
||||
// 禁用日期
|
||||
const isDateDisabled = (ts, type, range) => {
|
||||
const minDate = new Date("2009-10-06").getTime();
|
||||
const maxDate = new Date().getTime();
|
||||
|
||||
// 禁用过去的日期
|
||||
const disablePreviousDate = (date) => {
|
||||
return date < new Date(state.selectHistoricStartDate) || date > new Date()
|
||||
}
|
||||
if (ts < minDate || ts > maxDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 切换搜索区间开始日期
|
||||
const changeSearchRangeStartDate = (date) => {
|
||||
// console.error(date)
|
||||
changeSearchRange(
|
||||
'startDateTime',
|
||||
new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (type === "end" && range && range[0]) {
|
||||
return ts < range[0];
|
||||
}
|
||||
|
||||
// 切换搜索区间结束日期
|
||||
const changeSearchRangeEndDate = (date) => {
|
||||
// console.error(date)
|
||||
changeSearchRange(
|
||||
'endDateTime',
|
||||
new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
)
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 切换搜索区间
|
||||
const handleDateRangeChange = (range) => {
|
||||
if (range && range[0] && range[1]) {
|
||||
const startDate = new Date(range[0]).toLocaleDateString(locale.value, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const endDate = new Date(range[1]).toLocaleDateString(locale.value, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
changeSearchRange("startDateTime", startDate);
|
||||
changeSearchRange("endDateTime", endDate);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.custom-echarts {
|
||||
@ -550,19 +598,38 @@ const changeSearchRangeEndDate = (date) => {
|
||||
}
|
||||
|
||||
.echarts-header {
|
||||
.echarts-header-title {
|
||||
span {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #323232;
|
||||
}
|
||||
.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: 2rem 0 0;
|
||||
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;
|
||||
@ -586,6 +653,10 @@ const changeSearchRangeEndDate = (date) => {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
.activeRange {
|
||||
color: #fff;
|
||||
background: #ff7bac;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
679
src/components/customEcharts/size768/index.vue
Normal file
@ -0,0 +1,679 @@
|
||||
<template>
|
||||
<div class="custom-echarts">
|
||||
<div>
|
||||
<div class="echarts-header">
|
||||
<!-- 标题区域 -->
|
||||
<div class="title-section">
|
||||
<div class="title-decoration"></div>
|
||||
<div class="title-text">
|
||||
<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: 16 * 2.5px;
|
||||
margin-bottom: 32 * 2.5px;
|
||||
margin-top: 43 * 2.5px;
|
||||
padding: 0 16 * 2.5px;
|
||||
}
|
||||
|
||||
.title-decoration {
|
||||
width: 58 * 2.5px;
|
||||
height: 7 * 2.5px;
|
||||
background: #ff7bac;
|
||||
margin: auto 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-family: "PingFang SC", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 32 * 2.5px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.03em;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.echarts-search-area {
|
||||
padding: 0 16 * 2.5px 0 16 * 2.5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10 * 2.5px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.echarts-search-byRange {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10 * 2.5px;
|
||||
.search-range-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 16 * 2.5px;
|
||||
flex-wrap: wrap;
|
||||
.search-range-list-each {
|
||||
padding: 7 * 2.5px 22 * 2.5px;
|
||||
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;
|
||||
.n-date-picker {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,30 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { computed } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
|
||||
import size375 from '@/components/customFooter/size375/index.vue'
|
||||
import size768 from '@/components/customFooter/size768/index.vue'
|
||||
import size1440 from '@/components/customFooter/size1920/index.vue'
|
||||
import size1920 from '@/components/customFooter/size1920/index.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import size375 from "@/components/customFooter/size375/index.vue";
|
||||
import size768 from "@/components/customFooter/size768/index.vue";
|
||||
import size1440 from "@/components/customFooter/size1440/index.vue";
|
||||
import size1920 from "@/components/customFooter/size1920/index.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const router = useRouter()
|
||||
const { width } = useWindowSize()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
const { width } = useWindowSize();
|
||||
const { t } = useI18n();
|
||||
|
||||
const viewComponent = computed(() => {
|
||||
const viewWidth = width.value
|
||||
if (viewWidth <= 500) {
|
||||
return size375
|
||||
const viewWidth = width.value;
|
||||
if (viewWidth <= 450) {
|
||||
return size375;
|
||||
} else if (viewWidth <= 1100) {
|
||||
return size768
|
||||
return size768;
|
||||
} else if (viewWidth <= 1500) {
|
||||
return size1440
|
||||
return size1440;
|
||||
} else if (viewWidth <= 1920 || viewWidth > 1920) {
|
||||
return size1920
|
||||
return size1920;
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
68
src/components/customFooter/size1440/index.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<!-- 通用页脚 -->
|
||||
<div class="custom-footer">
|
||||
<div class="custom-footer-box">
|
||||
<div class="footer-links">
|
||||
<span @click="handleLink('privacyPolicy')">Privacy Policy</span>
|
||||
<span @click="handleLink('termsOfUse')">Terms of use</span>
|
||||
<span @click="handleLink('siteMap')">Site Map</span>
|
||||
</div>
|
||||
<span>© 2025 FiEE, Inc. All Rights Reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from "vue-router";
|
||||
const router = useRouter();
|
||||
import privacyPolicy from "@/assets/file/footer/FiEE, Inc. _ Privacy policy.pdf";
|
||||
import termsOfUse from "@/assets/file/footer/FiEE, Inc. _ Terms of Use.pdf";
|
||||
import siteMap from "@/assets/file/footer/FiEE, Inc. _ Site Map.pdf";
|
||||
|
||||
//点击跳转到对应的链接页面
|
||||
const handleLink = (link) => {
|
||||
// if (link === "privacyPolicy") {
|
||||
// window.open(privacyPolicy, "_blank");
|
||||
// } else if (link === "termsOfUse") {
|
||||
// window.open(termsOfUse, "_blank");
|
||||
// } else if (link === "siteMap") {
|
||||
// window.open(siteMap, "_blank");
|
||||
// }
|
||||
router.push(link);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-footer {
|
||||
width: 100%;
|
||||
background: #f5f5f5;
|
||||
border-top: 2px solid #dbdbdb;
|
||||
z-index: 100;
|
||||
height: 80px;
|
||||
|
||||
.custom-footer-box {
|
||||
width: 932px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
letter-spacing: 1px;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
span {
|
||||
border-right: 1px solid #d2d2d7;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
span:nth-last-child(1) {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -2,12 +2,12 @@
|
||||
<!-- 通用页脚 -->
|
||||
<div class="custom-footer">
|
||||
<div class="custom-footer-box">
|
||||
<span>© 2025 FiEE, Inc. All Rights Reserved.</span>
|
||||
<div class="footer-links">
|
||||
<span @click="handleLink('privacyPolicy')">Privacy Policy</span>
|
||||
<span @click="handleLink('termsOfUse')">Terms of use</span>
|
||||
<span @click="handleLink('siteMap')">Site Map</span>
|
||||
</div>
|
||||
<span>© 2025 FiEE, Inc. All Rights Reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -28,19 +28,20 @@ const handleLink = (link) => {
|
||||
// } else if (link === "siteMap") {
|
||||
// window.open(siteMap, "_blank");
|
||||
// }
|
||||
router.push(link)
|
||||
router.push(link);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-footer {
|
||||
width: 100%;
|
||||
background: #f7f8fa;
|
||||
border-top: 1px solid #ececec;
|
||||
background: #f5f5f5;
|
||||
border-top: 2px solid #dbdbdb;
|
||||
z-index: 100;
|
||||
height: 80px;
|
||||
|
||||
.custom-footer-box {
|
||||
max-width: 1700px;
|
||||
width: 932px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -50,12 +51,11 @@ const handleLink = (link) => {
|
||||
color: #888;
|
||||
// font-size: 15px;
|
||||
font-size: 1.05rem;
|
||||
padding: 1rem 40px;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
margin: 0.4rem 0 0;
|
||||
span {
|
||||
border-right: 1px solid #d2d2d7;
|
||||
padding: 0 10px;
|
||||
|
@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<!-- 通用页脚 -->
|
||||
<div class="custom-footer">
|
||||
<span>© 2025 FiEE, Inc. All Rights Reserved.</span>
|
||||
<div class="footer-links-box">
|
||||
<div class="footer-links">
|
||||
<span @click="handleLink('privacyPolicy')">Privacy Policy</span>
|
||||
@ -9,6 +8,7 @@
|
||||
<span @click="handleLink('siteMap')">Site Map</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-copyright">2025 FiEE, Inc. All Rights Reserved.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -29,7 +29,7 @@ const handleLink = (link) => {
|
||||
// } else if (link === "siteMap") {
|
||||
// window.open(siteMap, "_blank");
|
||||
// }
|
||||
router.push(link)
|
||||
router.push(link);
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -50,14 +50,13 @@ const handleLink = (link) => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0.6rem 0 0;
|
||||
|
||||
.footer-links {
|
||||
span {
|
||||
border-right: 1px solid #d2d2d7;
|
||||
padding: 0 0.8rem;
|
||||
padding: 0 16 * 5.12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-size: 14 * 5.12px;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
}
|
||||
@ -66,5 +65,11 @@ const handleLink = (link) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-copyright {
|
||||
margin-top: 12 * 5.12px;
|
||||
font-size: 14 * 5.12px;
|
||||
color: 455363;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<!-- 通用页脚 -->
|
||||
<div class="custom-footer">
|
||||
<span>© 2025 FiEE, Inc. All Rights Reserved.</span>
|
||||
<div class="footer-links">
|
||||
<span @click="handleLink('privacyPolicy')">Privacy Policy</span>
|
||||
<span @click="handleLink('termsOfUse')">Terms of use</span>
|
||||
<span @click="handleLink('siteMap')">Site Map</span>
|
||||
</div>
|
||||
<div>© 2025 FiEE, Inc. All Rights Reserved.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -26,29 +26,28 @@ const handleLink = (link) => {
|
||||
// } else if (link === "siteMap") {
|
||||
// window.open(siteMap, "_blank");
|
||||
// }
|
||||
router.push(link)
|
||||
router.push(link);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-footer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
padding: 24 * 2.5px 32 * 2.5px;
|
||||
color: #888;
|
||||
// font-size: 15px;
|
||||
font-size: 1.05rem;
|
||||
font-size: 14 * 2.5px;
|
||||
background: #f7f8fa;
|
||||
letter-spacing: 1px;
|
||||
border-top: 1px solid #ececec;
|
||||
z-index: 100;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.footer-links {
|
||||
margin: 0.4rem 0 0;
|
||||
span {
|
||||
border-right: 1px solid #d2d2d7;
|
||||
padding: 0 10px;
|
||||
padding: 0 16 * 2.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
span:nth-last-child(1) {
|
||||
|
@ -1,30 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { computed } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
|
||||
import size375 from '@/components/customHeader/size375/index.vue'
|
||||
import size768 from '@/components/customHeader/size375/index.vue'
|
||||
import size1440 from '@/components/customHeader/size1440/index.vue'
|
||||
import size1920 from '@/components/customHeader/size1920/index.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import size375 from "@/components/customHeader/size375/index.vue";
|
||||
import size768 from "@/components/customHeader/size768/index.vue";
|
||||
import size1440 from "@/components/customHeader/size1440/index.vue";
|
||||
import size1920 from "@/components/customHeader/size1920/index.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const router = useRouter()
|
||||
const { width } = useWindowSize()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
const { width } = useWindowSize();
|
||||
const { t } = useI18n();
|
||||
|
||||
const viewComponent = computed(() => {
|
||||
const viewWidth = width.value
|
||||
const viewWidth = width.value;
|
||||
if (viewWidth <= 450) {
|
||||
return size375
|
||||
} else if (viewWidth <= 835) {
|
||||
return size768
|
||||
} else if (viewWidth <= 1640) {
|
||||
return size1440
|
||||
return size375;
|
||||
} else if (viewWidth <= 1100) {
|
||||
return size768;
|
||||
} else if (viewWidth <= 1500) {
|
||||
return size1440;
|
||||
} else if (viewWidth <= 1920 || viewWidth > 1920) {
|
||||
return size1920
|
||||
return size1920;
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -6,16 +6,46 @@
|
||||
>
|
||||
<div class="header-container">
|
||||
<div class="logo" @click="handleToHome">
|
||||
<NImage width="80" height="80" :src="FiEELogo" preview-disabled />
|
||||
<NImage width="140" height="140" :src="FiEELogo" preview-disabled />
|
||||
</div>
|
||||
<div class="header-menu">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:options="menuOptions"
|
||||
:inverted="isScrolled"
|
||||
v-model:value="selectedKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
<NConfigProvider :theme-overrides="themeOverrides">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:options="menuOptions"
|
||||
:inverted="isScrolled"
|
||||
v-model:value="selectedKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</NConfigProvider>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="lang-switch-container">
|
||||
<div class="lang-switch" @click.stop="toggleLanguagePicker">
|
||||
<span class="lang-label">{{ currentLanguageLabel }}</span>
|
||||
<svg
|
||||
:class="{ rotated: showLanguagePicker }"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="7"
|
||||
height="4"
|
||||
viewBox="0 0 7 4"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.5 4L7 0L0 0L3.5 4Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<LanguagePicker
|
||||
v-if="showLanguagePicker"
|
||||
v-model="selectedLanguage"
|
||||
:options="languageOptions"
|
||||
@select="handleSelectLanguage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NLayoutHeader>
|
||||
@ -23,21 +53,78 @@
|
||||
|
||||
<script setup>
|
||||
import FiEELogo from "@/assets/image/header/logo.png";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { NMenu, NLayoutHeader, NImage } from "naive-ui";
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { NMenu, NLayoutHeader, NImage, NConfigProvider } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHeaderMenuConfig } from "@/config/headerMenuConfig";
|
||||
import LanguagePicker from "@/components/languagePicker/size1440/index.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const themeOverrides = {
|
||||
Menu: {
|
||||
// itemTextColor: "#ff7bac", // 默认文本颜色
|
||||
// 水平模式专用文本颜色(这些才会在水平模式下生效)
|
||||
// itemTextColorHorizontal: "#ff7bac", // 水平模式默认文本颜色
|
||||
itemTextColorHoverHorizontal: "#ff7bac", // 水平模式悬停文本颜色
|
||||
itemTextColorActiveHorizontal: "#ff7bac", // 水平模式激活文本颜色
|
||||
itemTextColorActiveHoverHorizontal: "#ff7bac", // 水平模式激活悬停文本颜色
|
||||
|
||||
itemColorHover: "#ff7bac",
|
||||
itemColorHoverInverted: "#ff7bac",
|
||||
},
|
||||
|
||||
// 关键:子菜单的悬停背景色在这里配置!
|
||||
Dropdown: {
|
||||
optionColorHover: "#fddfea", // 子菜单悬停背景色
|
||||
optionColorHoverInverted: "#fddfea", // 反转主题下的子菜单悬停背景色
|
||||
},
|
||||
};
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
// 使用统一的菜单配置
|
||||
const menuOptions = useHeaderMenuConfig();
|
||||
const { menuOptions } = useHeaderMenuConfig();
|
||||
const selectedKey = ref(null);
|
||||
|
||||
const isScrolled = ref(false);
|
||||
|
||||
// language picker
|
||||
|
||||
const showLanguagePicker = ref(false);
|
||||
const selectedLanguage = ref(
|
||||
localStorage.getItem("language") || locale.value || "en"
|
||||
);
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{ label: t("language.ja"), value: "ja", key: "ja" },
|
||||
{ label: t("language.en"), value: "en", key: "en" },
|
||||
{ label: t("language.zh"), value: "zh", key: "zh" },
|
||||
{ label: t("language.zhTW"), value: "zh-TW", key: "zh-TW" },
|
||||
]);
|
||||
|
||||
const currentLanguageLabel = computed(() => {
|
||||
const found = languageOptions.value.find(
|
||||
(opt) => opt.value === (locale.value || "en")
|
||||
);
|
||||
return found ? found.label : "English";
|
||||
});
|
||||
|
||||
const toggleLanguagePicker = () => {
|
||||
showLanguagePicker.value = !showLanguagePicker.value;
|
||||
};
|
||||
|
||||
const closeLanguagePicker = () => {
|
||||
showLanguagePicker.value = false;
|
||||
};
|
||||
|
||||
const handleSelectLanguage = (lang) => {
|
||||
locale.value = lang;
|
||||
localStorage.setItem("language", lang);
|
||||
closeLanguagePicker();
|
||||
selectedLanguage.value = locale.value;
|
||||
};
|
||||
|
||||
// 递归查找菜单项
|
||||
function findMenuOptionByKey(options, key) {
|
||||
for (const option of options) {
|
||||
@ -52,7 +139,7 @@ function findMenuOptionByKey(options, key) {
|
||||
|
||||
// 菜单点击跳转
|
||||
const handleMenuSelect = (key) => {
|
||||
const option = findMenuOptionByKey(menuOptions, key);
|
||||
const option = findMenuOptionByKey(menuOptions.value, key);
|
||||
if (option && option.href) {
|
||||
router.push(option.href);
|
||||
}
|
||||
@ -66,10 +153,12 @@ const handleScroll = () => {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
window.addEventListener("click", closeLanguagePicker);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("click", closeLanguagePicker);
|
||||
});
|
||||
|
||||
//点击回到首页
|
||||
@ -122,6 +211,12 @@ const handleToHome = () => {
|
||||
.header-menu {
|
||||
display: block;
|
||||
flex: 1;
|
||||
// :deep(
|
||||
// .n-menu-item-content:not(.n-menu-item-content--disabled):hover
|
||||
// .n-menu-item-content-header
|
||||
// ) {
|
||||
// color: #ff7bac;
|
||||
// }
|
||||
|
||||
:deep(.n-menu) {
|
||||
background: transparent;
|
||||
@ -132,12 +227,13 @@ const handleToHome = () => {
|
||||
position: relative;
|
||||
margin: 0 10px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 700;
|
||||
// font-size: 16px;
|
||||
font-size: 0.875rem;
|
||||
font-family: "PingFang SC";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
font-size: 16px;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@ -145,7 +241,7 @@ const handleToHome = () => {
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--primary-color);
|
||||
background: #ff7bac;
|
||||
transition: all 0.3s ease;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
@ -218,6 +314,29 @@ const handleToHome = () => {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.lang-switch-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
.lang-label {
|
||||
font-size: 12 * 1.33px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
color: #000;
|
||||
}
|
||||
svg {
|
||||
transition: transform 0.2s;
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.header-menu .n-menu .n-menu-item-content .n-menu-item-content-header {
|
||||
|
@ -6,16 +6,46 @@
|
||||
>
|
||||
<div class="header-container">
|
||||
<div class="logo" @click="handleToHome">
|
||||
<NImage width="80" height="80" :src="FiEELogo" preview-disabled />
|
||||
<NImage width="140" height="140" :src="FiEELogo" preview-disabled />
|
||||
</div>
|
||||
<div class="header-menu">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:options="menuOptions"
|
||||
:inverted="isScrolled"
|
||||
v-model:value="selectedKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
<NConfigProvider :theme-overrides="themeOverrides">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:options="menuOptions"
|
||||
:inverted="isScrolled"
|
||||
v-model:value="selectedKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</NConfigProvider>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="lang-switch-container">
|
||||
<div class="lang-switch" @click.stop="toggleLanguagePicker">
|
||||
<span class="lang-label">{{ currentLanguageLabel }}</span>
|
||||
<svg
|
||||
:class="{ rotated: showLanguagePicker }"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="7"
|
||||
height="4"
|
||||
viewBox="0 0 7 4"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.5 4L7 0L0 0L3.5 4Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<LanguagePicker
|
||||
v-if="showLanguagePicker"
|
||||
v-model="selectedLanguage"
|
||||
:options="languageOptions"
|
||||
@select="handleSelectLanguage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NLayoutHeader>
|
||||
@ -23,21 +53,75 @@
|
||||
|
||||
<script setup>
|
||||
import FiEELogo from "@/assets/image/header/logo.png";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { NMenu, NLayoutHeader, NImage } from "naive-ui";
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { NMenu, NLayoutHeader, NImage, NConfigProvider } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHeaderMenuConfig } from "@/config/headerMenuConfig";
|
||||
import LanguagePicker from "@/components/languagePicker/size1920/index.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const themeOverrides = {
|
||||
Menu: {
|
||||
// itemTextColor: "#ff7bac", // 默认文本颜色
|
||||
// 水平模式专用文本颜色(这些才会在水平模式下生效)
|
||||
// itemTextColorHorizontal: "#ff7bac", // 水平模式默认文本颜色
|
||||
itemTextColorHoverHorizontal: "#ff7bac", // 水平模式悬停文本颜色
|
||||
itemTextColorActiveHorizontal: "#ff7bac", // 水平模式激活文本颜色
|
||||
itemTextColorActiveHoverHorizontal: "#ff7bac", // 水平模式激活悬停文本颜色
|
||||
|
||||
itemColorHover: "#ff7bac",
|
||||
itemColorHoverInverted: "#ff7bac",
|
||||
},
|
||||
|
||||
// 关键:子菜单的悬停背景色在这里配置!
|
||||
Dropdown: {
|
||||
optionColorHover: "#fddfea", // 子菜单悬停背景色
|
||||
optionColorHoverInverted: "#fddfea", // 反转主题下的子菜单悬停背景色
|
||||
},
|
||||
};
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
// 使用统一的菜单配置
|
||||
const menuOptions = useHeaderMenuConfig();
|
||||
const { menuOptions } = useHeaderMenuConfig();
|
||||
const selectedKey = ref(null);
|
||||
|
||||
const isScrolled = ref(false);
|
||||
|
||||
const showLanguagePicker = ref(false);
|
||||
const selectedLanguage = ref(
|
||||
localStorage.getItem("language") || locale.value || "en"
|
||||
);
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{ label: t("language.ja"), value: "ja", key: "ja" },
|
||||
{ label: t("language.en"), value: "en", key: "en" },
|
||||
{ label: t("language.zh"), value: "zh", key: "zh" },
|
||||
{ label: t("language.zhTW"), value: "zh-TW", key: "zh-TW" },
|
||||
]);
|
||||
|
||||
const currentLanguageLabel = computed(() => {
|
||||
const found = languageOptions.value.find(
|
||||
(opt) => opt.value === (locale.value || "en")
|
||||
);
|
||||
return found ? found.label : "English";
|
||||
});
|
||||
|
||||
const toggleLanguagePicker = () => {
|
||||
showLanguagePicker.value = !showLanguagePicker.value;
|
||||
};
|
||||
|
||||
const closeLanguagePicker = () => {
|
||||
showLanguagePicker.value = false;
|
||||
};
|
||||
|
||||
const handleSelectLanguage = (lang) => {
|
||||
locale.value = lang;
|
||||
localStorage.setItem("language", lang);
|
||||
closeLanguagePicker();
|
||||
selectedLanguage.value = locale.value;
|
||||
};
|
||||
|
||||
// 递归查找菜单项
|
||||
function findMenuOptionByKey(options, key) {
|
||||
for (const option of options) {
|
||||
@ -52,7 +136,7 @@ function findMenuOptionByKey(options, key) {
|
||||
|
||||
// 菜单点击跳转
|
||||
const handleMenuSelect = (key) => {
|
||||
const option = findMenuOptionByKey(menuOptions, key);
|
||||
const option = findMenuOptionByKey(menuOptions.value, key);
|
||||
if (option && option.href) {
|
||||
router.push(option.href);
|
||||
}
|
||||
@ -66,10 +150,12 @@ const handleScroll = () => {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
window.addEventListener("click", closeLanguagePicker);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("click", closeLanguagePicker);
|
||||
});
|
||||
|
||||
//点击回到首页
|
||||
@ -132,12 +218,13 @@ const handleToHome = () => {
|
||||
position: relative;
|
||||
margin: 0 20px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 700;
|
||||
// font-size: 16px;
|
||||
font-size: 1.05rem;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
|
||||
font-family: "PingFang SC";
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@ -145,7 +232,7 @@ const handleToHome = () => {
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--primary-color);
|
||||
background: #ff7bac;
|
||||
transition: all 0.3s ease;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
@ -218,4 +305,32 @@ const handleToHome = () => {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-left: auto;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.lang-switch-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
.lang-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
color: #000;
|
||||
}
|
||||
svg {
|
||||
transition: transform 0.2s;
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -7,56 +7,132 @@
|
||||
<div class="header-container">
|
||||
<div class="logo" @click="handleToHome">
|
||||
<NImage
|
||||
style="width: 60px; height: 60px; max-width: 100%"
|
||||
style="width: 100px; max-width: 100%"
|
||||
:src="FiEELogo"
|
||||
preview-disabled
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="menu-btn"
|
||||
:class="{ 'menu-open': showMenu }"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<n-icon size="28" class="menu-icon menu-icon-menu">
|
||||
<menu-sharp />
|
||||
</n-icon>
|
||||
<n-icon size="28" class="menu-icon menu-icon-close">
|
||||
<close-sharp />
|
||||
</n-icon>
|
||||
<div class="header-right">
|
||||
<div v-show="!showMenu" class="lang-switch" @click="openLanguagePicker">
|
||||
<span class="lang-label">{{ currentLanguageLabel }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="7"
|
||||
height="4"
|
||||
viewBox="0 0 7 4"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.5 4L7 0L0 0L3.5 4Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="menu-btn"
|
||||
:class="{ 'menu-open': showMenu }"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<img
|
||||
v-if="showMenu"
|
||||
src="@/assets/image/375/menu-close.png"
|
||||
alt="menu"
|
||||
class="menu-icon"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="@/assets/image/375/menu-open.png"
|
||||
alt="menu"
|
||||
class="menu-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NLayoutHeader>
|
||||
<transition name="fade-slide">
|
||||
<div v-if="showMenu" class="mobile-menu-wrapper" @click.self="closeMenu">
|
||||
<NMenu
|
||||
mode="vertical"
|
||||
:options="menuOptions"
|
||||
:inverted="isScrolled"
|
||||
class="mobile-menu"
|
||||
accordion
|
||||
v-model:value="selectedKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
<NConfigProvider :theme-overrides="themeOverrides">
|
||||
<NMenu
|
||||
mode="vertical"
|
||||
:options="menuOptions"
|
||||
:inverted="isScrolled"
|
||||
class="mobile-menu"
|
||||
accordion
|
||||
v-model:value="selectedKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</NConfigProvider>
|
||||
</div>
|
||||
</transition>
|
||||
<LanguagePicker
|
||||
v-if="showLanguagePicker"
|
||||
v-model="selectedLanguage"
|
||||
:options="languageOptions"
|
||||
@close="showLanguagePicker = false"
|
||||
@confirm="handleConfirmLanguage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FiEELogo from "@/assets/image/header/logo.png";
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { NMenu, NLayoutHeader, NImage, NIcon } from "naive-ui";
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { NMenu, NLayoutHeader, NImage, NIcon, NConfigProvider } from "naive-ui";
|
||||
import { MenuSharp, CloseSharp } from "@vicons/ionicons5";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHeaderMenuConfig } from "@/config/headerMenuConfig";
|
||||
|
||||
const { t } = useI18n();
|
||||
import LanguagePicker from "@/components/languagePicker/index.vue";
|
||||
const themeOverrides = {
|
||||
Menu: {
|
||||
itemTextColorHover: "#000",
|
||||
itemTextColorActive: "#FF7BAC",
|
||||
itemTextColorActiveHover: "#fff8fb",
|
||||
itemColorHover: "#FDDFE9",
|
||||
itemColorActive: "#fff",
|
||||
itemColorActiveHover: "#fff8fb",
|
||||
},
|
||||
};
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const isScrolled = ref(false);
|
||||
const showMenu = ref(false);
|
||||
const selectedKey = ref(null);
|
||||
|
||||
// language picker
|
||||
const showLanguagePicker = ref(false);
|
||||
const selectedLanguage = ref(
|
||||
localStorage.getItem("language") || locale.value || "en"
|
||||
);
|
||||
const languageOptions = computed(() => [
|
||||
{ label: t("language.ja"), value: "ja", key: "ja" },
|
||||
{ label: t("language.en"), value: "en", key: "en" },
|
||||
{ label: t("language.zh"), value: "zh", key: "zh" },
|
||||
{ label: t("language.zhTW"), value: "zh-TW", key: "zh-TW" },
|
||||
]);
|
||||
const currentLanguageLabel = computed(() => {
|
||||
const found = languageOptions.value.find(
|
||||
(opt) => opt.value === (locale.value || "en")
|
||||
);
|
||||
return found ? found.label : "English";
|
||||
});
|
||||
|
||||
const openLanguagePicker = () => {
|
||||
if (showMenu.value) {
|
||||
return;
|
||||
}
|
||||
showLanguagePicker.value = true;
|
||||
selectedLanguage.value = locale.value;
|
||||
};
|
||||
|
||||
const handleConfirmLanguage = (lang) => {
|
||||
locale.value = lang;
|
||||
localStorage.setItem("language", lang);
|
||||
showLanguagePicker.value = false;
|
||||
};
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value;
|
||||
};
|
||||
@ -78,7 +154,7 @@ function findMenuOptionByKey(options, key) {
|
||||
|
||||
// 菜单点击跳转
|
||||
const handleMenuSelect = (key) => {
|
||||
const option = findMenuOptionByKey(menuOptions, key);
|
||||
const option = findMenuOptionByKey(menuOptions.value, key);
|
||||
if (option && option.href) {
|
||||
router.push(option.href);
|
||||
showMenu.value = false; // 跳转后收起菜单
|
||||
@ -86,7 +162,7 @@ const handleMenuSelect = (key) => {
|
||||
};
|
||||
|
||||
// 使用统一的菜单配置
|
||||
const menuOptions = useHeaderMenuConfig();
|
||||
const { menuOptions } = useHeaderMenuConfig();
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
@ -114,7 +190,7 @@ const handleToHome = () => {
|
||||
.custom-header {
|
||||
transition: all 0.3s ease;
|
||||
background: transparent;
|
||||
height: 320px;
|
||||
height: 60 * 5.12px;
|
||||
|
||||
&.header-scrolled {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
@ -133,6 +209,19 @@ const handleToHome = () => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11 * 5.12px;
|
||||
font-weight: 600;
|
||||
font-size: 14 * 5.12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.lang-caret {
|
||||
font-size: 10 * 5.12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
@ -141,8 +230,7 @@ const handleToHome = () => {
|
||||
}
|
||||
|
||||
.logo :deep(.n-image) {
|
||||
max-width: 100px;
|
||||
height: auto;
|
||||
width: 94 * 5.12px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
@ -157,6 +245,7 @@ const handleToHome = () => {
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
width: 56 * 5.12px;
|
||||
|
||||
.menu-icon {
|
||||
position: absolute;
|
||||
@ -187,18 +276,40 @@ const handleToHome = () => {
|
||||
|
||||
.mobile-menu-wrapper {
|
||||
position: fixed;
|
||||
top: 320px;
|
||||
top: 60 * 5.12px;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
background: rgba(220, 207, 248, 0.95);
|
||||
background: #fff;
|
||||
z-index: 1100;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08);
|
||||
padding: 40px 0 80px 0;
|
||||
max-height: 1500px;
|
||||
max-height: 1500 * 5.12px;
|
||||
overflow-y: auto;
|
||||
|
||||
// 设置CSS变量,只影响当前组件的菜单
|
||||
// --n-item-text-color-child-active: #ff7bac;
|
||||
|
||||
:deep(.n-menu-item) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// // 强制覆盖子菜单激活状态的文字颜色
|
||||
// :deep(
|
||||
// .n-menu
|
||||
// .n-menu-item-content.n-menu-item-content--child-active
|
||||
// .n-menu-item-content-header
|
||||
// ) {
|
||||
// color: #ff7bac !important;
|
||||
// }
|
||||
|
||||
// // 强制覆盖所有子菜单相关的激活状态
|
||||
// :deep(.n-menu-item-content--child-active) {
|
||||
// color: #ff7bac !important;
|
||||
|
||||
// .n-menu-item-content-header {
|
||||
// color: #ff7bac !important;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
@ -210,4 +321,9 @@ const handleToHome = () => {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24 * 5.12px;
|
||||
}
|
||||
</style>
|
||||
|
302
src/components/customHeader/size768/index.vue
Normal file
@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<!-- 通用页头 -->
|
||||
<NLayoutHeader
|
||||
class="custom-header"
|
||||
:class="{ 'header-scrolled': isScrolled }"
|
||||
>
|
||||
<div class="header-container">
|
||||
<div class="logo" @click="handleToHome">
|
||||
<NImage class="logo-image" :src="FiEELogo" preview-disabled />
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div v-show="!showMenu" class="lang-switch" @click="openLanguagePicker">
|
||||
<span class="lang-label">{{ currentLanguageLabel }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="7"
|
||||
height="4"
|
||||
viewBox="0 0 7 4"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.5 4L7 0L0 0L3.5 4Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="menu-btn"
|
||||
:class="{ 'menu-open': showMenu }"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<img
|
||||
v-if="showMenu"
|
||||
src="@/assets/image/768/menu-close.png"
|
||||
alt="menu"
|
||||
class="menu-icon"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="@/assets/image/768/menu-open.png"
|
||||
alt="menu"
|
||||
class="menu-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NLayoutHeader>
|
||||
<transition name="fade-slide">
|
||||
<div v-if="showMenu" class="mobile-menu-wrapper" @click.self="closeMenu">
|
||||
<NConfigProvider :theme-overrides="themeOverrides">
|
||||
<NMenu
|
||||
mode="vertical"
|
||||
:options="menuOptions"
|
||||
:inverted="isScrolled"
|
||||
class="mobile-menu"
|
||||
accordion
|
||||
v-model:value="selectedKey"
|
||||
@update:value="handleMenuSelect"
|
||||
/>
|
||||
</NConfigProvider>
|
||||
</div>
|
||||
</transition>
|
||||
<LanguagePicker
|
||||
v-if="showLanguagePicker"
|
||||
v-model="selectedLanguage"
|
||||
:options="languageOptions"
|
||||
@close="showLanguagePicker = false"
|
||||
@confirm="handleConfirmLanguage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FiEELogo from "@/assets/image/header/logo.png";
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { NMenu, NLayoutHeader, NImage, NIcon, NConfigProvider } from "naive-ui";
|
||||
import { MenuSharp, CloseSharp } from "@vicons/ionicons5";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useHeaderMenuConfig } from "@/config/headerMenuConfig";
|
||||
import LanguagePicker from "@/components/languagePicker/index.vue";
|
||||
const themeOverrides = {
|
||||
Menu: {
|
||||
itemTextColorHover: "#000",
|
||||
itemTextColorActive: "#FF7BAC",
|
||||
itemTextColorActiveHover: "#fff8fb",
|
||||
itemColorHover: "#FDDFE9",
|
||||
itemColorActive: "#fff",
|
||||
itemColorActiveHover: "#fff8fb",
|
||||
},
|
||||
};
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const isScrolled = ref(false);
|
||||
const showMenu = ref(false);
|
||||
const selectedKey = ref(null);
|
||||
|
||||
// language picker
|
||||
const showLanguagePicker = ref(false);
|
||||
const selectedLanguage = ref(
|
||||
localStorage.getItem("language") || locale.value || "en"
|
||||
);
|
||||
const languageOptions = computed(() => [
|
||||
{ label: t("language.ja"), value: "ja", key: "ja" },
|
||||
{ label: t("language.en"), value: "en", key: "en" },
|
||||
{ label: t("language.zh"), value: "zh", key: "zh" },
|
||||
{ label: t("language.zhTW"), value: "zh-TW", key: "zh-TW" },
|
||||
]);
|
||||
const currentLanguageLabel = computed(() => {
|
||||
const found = languageOptions.value.find(
|
||||
(opt) => opt.value === (locale.value || "en")
|
||||
);
|
||||
return found ? found.label : "English";
|
||||
});
|
||||
|
||||
const openLanguagePicker = () => {
|
||||
if (showMenu.value) {
|
||||
return;
|
||||
}
|
||||
showLanguagePicker.value = true;
|
||||
selectedLanguage.value = locale.value;
|
||||
};
|
||||
|
||||
const handleConfirmLanguage = (lang) => {
|
||||
locale.value = lang;
|
||||
localStorage.setItem("language", lang);
|
||||
showLanguagePicker.value = false;
|
||||
};
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value;
|
||||
};
|
||||
const closeMenu = () => {
|
||||
showMenu.value = false;
|
||||
};
|
||||
|
||||
// 递归查找菜单项
|
||||
function findMenuOptionByKey(options, key) {
|
||||
for (const option of options) {
|
||||
if (option.key === key) return option;
|
||||
if (option.children) {
|
||||
const found = findMenuOptionByKey(option.children, key);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 菜单点击跳转
|
||||
const handleMenuSelect = (key) => {
|
||||
const option = findMenuOptionByKey(menuOptions.value, key);
|
||||
if (option && option.href) {
|
||||
router.push(option.href);
|
||||
showMenu.value = false; // 跳转后收起菜单
|
||||
}
|
||||
};
|
||||
|
||||
// 使用统一的菜单配置
|
||||
const { menuOptions } = useHeaderMenuConfig();
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
//滚动距离大于100*2.5px时,处理对应的header样式
|
||||
isScrolled.value = window.scrollY >= 100;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
//点击回到首页
|
||||
const handleToHome = () => {
|
||||
router.push("/");
|
||||
selectedKey.value = null; // 重置菜单选中状态
|
||||
showMenu.value = false; // 在移动端同时关闭菜单
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-header {
|
||||
transition: all 0.3s ease;
|
||||
background: transparent;
|
||||
height: 60 * 2.5px;
|
||||
|
||||
&.header-scrolled {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 2 * 2.5px 8 * 2.5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.header-container {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0 59 * 2.5px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24 * 2.5px;
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11 * 2.5px;
|
||||
font-weight: 600;
|
||||
font-size: 14 * 2.5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
flex-shrink: 0;
|
||||
margin-left: 11 * 2.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 120 * 2.5px;
|
||||
height: 27 * 2.5px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 75 * 2.5px;
|
||||
padding: 20 * 2.5px;
|
||||
border-radius: 30 * 2.5px;
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
width: 56 * 2.5px;
|
||||
|
||||
.menu-icon {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.menu-icon-close {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) rotate(-90deg) scale(0.8);
|
||||
}
|
||||
|
||||
&.menu-open {
|
||||
.menu-icon-menu {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) rotate(90deg) scale(0.8);
|
||||
}
|
||||
.menu-icon-close {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-wrapper {
|
||||
position: fixed;
|
||||
top: 60 * 2.5px;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
background: #fff;
|
||||
z-index: 1100;
|
||||
box-shadow: 0 30 * 2.5px 40 * 2.5px rgba(0, 0, 0, 0.08);
|
||||
padding: 40 * 2.5px;
|
||||
max-height: 1500 * 2.5px;
|
||||
overflow-y: auto;
|
||||
:deep(.n-menu-item) {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.fade-slide-enter-from,
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-50 * 2.5px);
|
||||
}
|
||||
</style>
|