<template>
|
<div class="quotes-market-page">
|
<!-- Top Tabs -->
|
<div class="market-tabs">
|
<!-- <div class="tab-item" :class="{ active: activeTab === 'optional' }" @click="activeTab = 'optional'">
|
{{ $t('自选') }}
|
</div> -->
|
<div class="tab-item" :class="{ active: activeTab === 'forex' }" @click="activeTab = 'forex'">
|
{{ $t('外汇') }}
|
</div>
|
<div class="tab-item" :class="{ active: activeTab === 'crypto' }" @click="activeTab = 'crypto'">
|
{{ $t('加密货币') }}
|
</div>
|
<div class="tab-item" :class="{ active: activeTab === 'stock' }" @click="activeTab = 'stock'">
|
{{ $t('股票') }}
|
</div>
|
<div class="tab-item" :class="{ active: activeTab === 'etf' }" @click="activeTab = 'etf'">
|
ETF
|
</div>
|
</div>
|
|
<!-- Trading Pairs List(上拉加载,需在固定高度滚动容器内并指定 scroll-target) -->
|
<div ref="marketListRef" class="market-list market-list--scroll">
|
<van-list v-model:loading="marketLoading" :finished="marketFinished" :immediate-check="false"
|
:scroll-target="marketListRef" :finished-text="$t('没有更多了') || '没有更多了'" @load="loadMoreMarket">
|
<div class="pair-item" v-for="pair in tradingPairs" :key="pair.symbol"
|
@click="goToOptions(pair.symbol, pair.type)">
|
<div class="pair-header">
|
<div class="pair-symbol">
|
<template v-if="activeTab === 'forex' && getPairIconUrl(pair)">
|
<div class="pair-symbol-icon-wrap">
|
<img :src="getPairIconUrl(pair)" alt=""
|
class="pair-symbol-icon pair-symbol-icon--large" />
|
<img v-if="getPairIconUrlSm(pair)" :src="getPairIconUrlSm(pair)" alt=""
|
class="pair-symbol-icon pair-symbol-icon--sm" />
|
</div>
|
</template>
|
<img v-else-if="getPairIconUrl(pair)" :src="getPairIconUrl(pair)" alt=""
|
class="pair-symbol-icon" />
|
{{ pair.symboltxt.toUpperCase() }}
|
</div>
|
<div class="pair-change" :class="pair.change >= 0 ? 'up' : 'down'">
|
{{ pair.change >= 0 ? '+' : '' }}{{ pair.change.toFixed(4) }}%
|
</div>
|
</div>
|
<div class="pair-bottom">
|
<button class="sell">
|
<span class="action-price">{{ pair.sellPrice }}</span>
|
<span class="action-label action-btn">{{ $t('卖出') }}</span>
|
</button>
|
<div class="pair-chart">
|
<MiniKlineChart :key="pair.symbol" :data="pair.klineData || []" :up="pair.change >= 0" />
|
</div>
|
<button class="buy">
|
<span class="action-price">{{ pair.buyPrice }}</span>
|
<span class="action-label action-btn">{{ $t('买入') }}</span>
|
</button>
|
</div>
|
</div>
|
</van-list>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
|
import { useI18n } from 'vue-i18n'
|
import { useRouter } from 'vue-router'
|
import { List as VanList } from 'vant'
|
import { _getRealtimeByType } from '@/service/quotes.api'
|
import { _listItemsById, _itemUserOptionalList, _getQuotes } from '@/service/quotes.api'
|
import { useUserStore } from '@/store/user'
|
import { useQuotesStore } from '@/store/quotes.store'
|
import { OPCIONA_LIST } from '@/store/types.store'
|
import { IMG_PATH } from '@/config'
|
import MiniKlineChart from '@/components/MiniKlineChart/index.vue'
|
|
// 外汇货币代码 -> 国旗图国家/地区代码(与首页一致)
|
const CURRENCY_TO_FLAG = {
|
eur: 'eu', usd: 'us', gbp: 'gb', jpy: 'jp', chf: 'ch', aud: 'au', cad: 'ca', nzd: 'nz',
|
cny: 'cn', cnh: 'cn', hkd: 'hk', sgd: 'sg', nok: 'no', sek: 'se', dkk: 'dk', mxn: 'mx',
|
zar: 'za', try: 'tr', pln: 'pl', inr: 'in', krw: 'kr', thb: 'th', myr: 'my', idr: 'id',
|
php: 'ph', brl: 'br', rub: 'ru', czk: 'cz', huf: 'hu', ron: 'ro', bgn: 'bg', hrk: 'hr'
|
}
|
const FLAG_CDN = 'https://flagcdn.com/w40'
|
|
const { t } = useI18n()
|
const router = useRouter()
|
const useStore = useUserStore()
|
const quotesStore = useQuotesStore()
|
|
const activeTab = ref('crypto')
|
const tradingPairs = ref([])
|
const interval = ref(null)
|
const marketPage = ref(1)
|
const marketLoading = ref(false)
|
const marketFinished = ref(false)
|
const marketListRef = ref(null)
|
const marketInitialLoading = ref(false) // tab 切换时首屏请求中,避免 @load 重复请求 pageNo=1
|
const MARKET_PAGE_SIZE = 10
|
|
// 从外汇对取基础货币代码,与首页一致
|
function getForexBaseCurrency(symbol) {
|
if (!symbol || typeof symbol !== 'string') return ''
|
const s = symbol.trim()
|
if (s.includes('/')) return s.split('/')[0].trim().toLowerCase()
|
return s.slice(0, 3).toLowerCase()
|
}
|
|
// 从外汇对取计价货币代码,如 EUR/USD -> usd(右下角小图用)
|
function getForexQuoteCurrency(symbol) {
|
if (!symbol || typeof symbol !== 'string') return ''
|
const s = symbol.trim()
|
if (s.includes('/')) return s.split('/')[1]?.trim().toLowerCase() || ''
|
return s.length > 3 ? s.slice(3, 6).toLowerCase() : ''
|
}
|
|
// 列表项图标地址:外汇用国旗,加密货币用 symbol 图;股票、ETF 不展示图标;自选按 type 判断
|
function getPairIconUrl(pair) {
|
if (!pair) return ''
|
const tab = activeTab.value
|
if (tab === 'stock' || tab === 'etf') return ''
|
if (tab === 'optional' && (pair.type === 'US-stocks' || pair.type === 'indices')) return ''
|
if (tab === 'forex') {
|
const code = CURRENCY_TO_FLAG[getForexBaseCurrency(pair.symbol)]
|
return code ? `${FLAG_CDN}/${code}.png` : ''
|
}
|
return pair.iconImg ? `${IMG_PATH}/symbol/${pair.iconImg}.png` : ''
|
}
|
|
// 小图用名字后面3位(计价货币)的国旗,仅外汇有效
|
function getPairIconUrlSm(pair) {
|
if (!pair || activeTab.value !== 'forex') return ''
|
const quote = getForexQuoteCurrency(pair.symbol)
|
if (!quote) return ''
|
const code = CURRENCY_TO_FLAG[quote]
|
return code ? `${FLAG_CDN}/${code}.png` : ''
|
}
|
|
// 根据当前价与涨跌幅生成小型 K 线数据,与首页一致
|
function generateMiniKlineData(basePrice, changeRatio) {
|
const candleCount = 12
|
const startPrice = basePrice / (1 + (changeRatio || 0) / 100)
|
const range = basePrice - startPrice
|
const candles = []
|
let prevClose = startPrice
|
for (let i = 0; i < candleCount; i++) {
|
const t = (i + 1) / candleCount
|
const trend = startPrice + range * t
|
const noise = (Math.random() - 0.5) * (Math.abs(range) * 0.15 + basePrice * 0.002)
|
const close = i === candleCount - 1 ? basePrice : Math.max(basePrice * 0.001, trend + noise)
|
const open = prevClose
|
const low = Math.min(open, close) - Math.random() * basePrice * 0.001
|
const high = Math.max(open, close) + Math.random() * basePrice * 0.001
|
candles.push([open, close, low, high])
|
prevClose = close
|
}
|
return candles
|
}
|
|
// 获取自选数据
|
const fetchOptionalData = async () => {
|
try {
|
// 清除之前的定时器
|
if (interval.value) {
|
clearInterval(interval.value)
|
interval.value = null
|
}
|
|
if (!useStore.userInfo.token) {
|
// 未登录时使用默认列表
|
const defaultSymbols = [
|
{ symbol: 'AAPL' },
|
{ symbol: 'MSFT' },
|
{ symbol: 'btc' },
|
{ symbol: 'eth' },
|
{ symbol: 'SH518880' },
|
{ symbol: '.IXIC' },
|
{ symbol: '.DJI' },
|
]
|
quotesStore[OPCIONA_LIST](defaultSymbols)
|
fetchOptionalQuotes()
|
return
|
}
|
|
// 获取用户自选列表
|
const listData = await _itemUserOptionalList()
|
if (listData && listData.list && listData.list.length > 0) {
|
// 获取第一个自选列表的详情
|
const firstList = listData.list[0]
|
const itemsData = await _listItemsById({ id: firstList.listId })
|
|
if (itemsData && Array.isArray(itemsData)) {
|
// 将symbols放入store
|
const symbols = itemsData.map(item => ({ symbol: item.symbol }))
|
quotesStore[OPCIONA_LIST](symbols)
|
fetchOptionalQuotes()
|
} else {
|
quotesStore[OPCIONA_LIST]([])
|
tradingPairs.value = []
|
}
|
} else {
|
quotesStore[OPCIONA_LIST]([])
|
tradingPairs.value = []
|
}
|
} catch (error) {
|
console.error('获取自选数据失败:', error)
|
tradingPairs.value = []
|
}
|
}
|
|
// 获取自选实时报价
|
const fetchOptionalQuotes = () => {
|
if (quotesStore.$state.opcionalList.length > 0) {
|
_getQuotes(quotesStore.$state.opcionalList).then((data) => {
|
if (data && Array.isArray(data)) {
|
tradingPairs.value = data.map(item => {
|
const basePrice = parseFloat(item.close || 0)
|
const changeRatio = item.changeRatio || item.change_ratio || 0
|
const symbolStr = item.symbol || '--'
|
const iconImg = item.symbol_data || (symbolStr.includes('/') ? symbolStr.split('/')[0].toLowerCase() : symbolStr.replace(/USDT$/i, '').toLowerCase()) || symbolStr.toLowerCase()
|
const klineData = generateMiniKlineData(basePrice, changeRatio)
|
const spread = basePrice * 0.0001
|
const sellPrice = (basePrice - spread).toFixed(4)
|
const buyPrice = (basePrice + spread).toFixed(4)
|
|
return {
|
symbol: symbolStr,
|
price: basePrice.toFixed(4),
|
change: changeRatio,
|
sellPrice: sellPrice,
|
buyPrice: buyPrice,
|
klineData: klineData,
|
iconImg: iconImg,
|
type: item.type
|
}
|
})
|
} else {
|
tradingPairs.value = []
|
}
|
}).catch(error => {
|
console.error('获取自选报价失败:', error)
|
tradingPairs.value = []
|
})
|
} else {
|
tradingPairs.value = []
|
}
|
}
|
|
// 定时更新自选数据
|
const startOptionalInterval = () => {
|
if (interval.value) {
|
clearInterval(interval.value)
|
}
|
interval.value = setInterval(() => {
|
if (activeTab.value === 'optional') {
|
fetchOptionalQuotes()
|
}
|
}, 2000)
|
}
|
|
// 获取交易数据;pageNo 页码,append 是否追加
|
const fetchTradingData = async (pageNo = 1, append = false) => {
|
let type = ''
|
let category = null
|
|
switch (activeTab.value) {
|
case 'crypto':
|
type = 'cryptos'
|
break
|
case 'etf':
|
type = 'indices'
|
break
|
case 'stock':
|
type = 'US-stocks'
|
break
|
case 'forex':
|
type = 'forex'
|
category = 'forex'
|
break
|
default:
|
type = 'forex'
|
category = 'forex'
|
}
|
|
try {
|
const params = {
|
type: type,
|
pageNo: pageNo,
|
pageSize: MARKET_PAGE_SIZE
|
}
|
if (category) {
|
params.category = category
|
}
|
|
const data = await _getRealtimeByType(params)
|
|
if (data && Array.isArray(data)) {
|
const list = data.map(item => {
|
const basePrice = parseFloat(item.close || item.lastPrice || 0)
|
const changeRatio = item.changeRatio || 0
|
const symbolStr = item.symbol || '--'
|
const symboltxt = item.enName
|
const iconImg = item.symbol_data || (symbolStr.includes('/') ? symbolStr.split('/')[0].toLowerCase() : symbolStr.replace(/USDT$/i, '').toLowerCase()) || symbolStr.toLowerCase()
|
const klineData = generateMiniKlineData(basePrice, changeRatio)
|
const spread = basePrice * 0.0001
|
const sellPrice = (basePrice - spread).toFixed(4)
|
const buyPrice = (basePrice + spread).toFixed(4)
|
|
return {
|
symbol: symbolStr,
|
symboltxt: symboltxt,
|
price: basePrice.toFixed(4),
|
change: changeRatio,
|
sellPrice: sellPrice,
|
buyPrice: buyPrice,
|
klineData: klineData,
|
symbol: symbolStr,
|
type: type,
|
iconImg: iconImg,
|
price: basePrice.toFixed(4),
|
change: changeRatio,
|
sellPrice: sellPrice,
|
buyPrice: buyPrice,
|
klineData: klineData
|
}
|
})
|
if (append) {
|
tradingPairs.value = [...tradingPairs.value, ...list]
|
} else {
|
tradingPairs.value = list
|
}
|
marketLoading.value = false
|
if (data.length <= 0) {
|
marketFinished.value = true
|
}
|
} else {
|
if (!append) tradingPairs.value = []
|
marketFinished.value = true
|
}
|
} catch (error) {
|
console.error('获取交易数据失败:', error)
|
if (!append) tradingPairs.value = []
|
marketFinished.value = true
|
}
|
}
|
|
// 上拉触底加载更多(仅非自选 tab 分页,pageSize=10)
|
const loadMoreMarket = async () => {
|
if (activeTab.value === 'optional') {
|
marketFinished.value = true
|
marketLoading.value = false
|
return
|
}
|
if (marketInitialLoading.value) {
|
marketLoading.value = false
|
return
|
}
|
// if (marketLoading.value || marketFinished.value) return
|
// marketLoading.value = true
|
try {
|
await fetchTradingData(marketPage.value, true)
|
marketPage.value += 1
|
} finally {
|
marketLoading.value = false
|
}
|
}
|
|
// 跳转到交易页 Options,与首页一致:/trade/options?symbol=xxx&activeTab=xxx
|
function goToOptions(symbol, type) {
|
if (!symbol) return
|
const tabMap = { crypto: 'cryptos', etf: 'indices', stock: 'US-stocks', forex: 'forex', optional: 'optional' }
|
const activeTabValue = type || tabMap[activeTab.value] || 'cryptos'
|
router.push({
|
path: '/trade/options',
|
query: { symbol, activeTab: activeTabValue }
|
})
|
}
|
|
// 获取数据
|
const fetchData = async () => {
|
if (interval.value) {
|
clearInterval(interval.value)
|
interval.value = null
|
}
|
|
marketPage.value = 1
|
marketFinished.value = false
|
marketInitialLoading.value = true
|
try {
|
if (activeTab.value === 'optional') {
|
await fetchOptionalData()
|
startOptionalInterval()
|
marketFinished.value = true
|
} else {
|
await fetchTradingData(1, false)
|
marketPage.value = 2
|
}
|
// tab 切换后列表滚动回顶部
|
await nextTick()
|
if (marketListRef.value) marketListRef.value.scrollTop = 0
|
} finally {
|
marketInitialLoading.value = false
|
}
|
}
|
|
// 监听 tab 切换
|
watch(activeTab, () => {
|
fetchData()
|
})
|
|
// 处理列表项点击
|
const handleItemClick = (pair) => {
|
// 根据类型跳转到不同的详情页
|
const type = pair.type || activeTab.value
|
if (type === 'cryptos' || type === 'crypto') {
|
router.push(`/cryptos/trendDetails/${pair.symbol}?isOptional=1&type=cryptos`)
|
} else if (type === 'indices' || type === 'etf') {
|
router.push(`/quotes/detail?symbol=${pair.symbol}&isOptional=1&symbolType=indices&type=indices`)
|
} else if (type === 'US-stocks' || type === 'stock') {
|
router.push(`/quotes/usStockDetail?symbol=${pair.symbol}&isOptional=1&symbolType=US-stocks`)
|
} else {
|
router.push(`/foreign/coinChart?symbol=${pair.symbol}&isOptional=1`)
|
}
|
}
|
|
// 组件挂载时获取数据
|
onMounted(() => {
|
fetchData()
|
})
|
|
// 组件卸载时清除定时器
|
onBeforeUnmount(() => {
|
if (interval.value) {
|
clearInterval(interval.value)
|
interval.value = null
|
}
|
})
|
</script>
|
|
<style lang="scss" scoped>
|
.quotes-market-page {
|
min-height: 100vh;
|
background: #fff;
|
padding-bottom: 6rem;
|
padding: 3rem 0;
|
}
|
|
/* Top Tabs */
|
.market-tabs {
|
display: flex;
|
border: 0.1rem solid #e0e0e0;
|
background: #fff;
|
border-radius: 1rem;
|
padding: 0.3rem;
|
overflow-x: auto;
|
position: sticky;
|
top: 0;
|
z-index: 10;
|
gap: 0.3rem;
|
margin: 0 2rem 2rem;
|
|
.tab-item {
|
font-size: 2.5rem;
|
padding: 1.5rem 0;
|
color: $text_color1;
|
white-space: nowrap;
|
cursor: pointer;
|
transition: all 0.3s ease;
|
border-radius: 0.8rem;
|
flex: 1;
|
text-align: center;
|
font-weight: 500;
|
|
&.active {
|
color: #fff;
|
background: #0a6bfa;
|
border-color: #0a6bfa;
|
font-weight: 600;
|
padding: 1.5rem 2rem;
|
}
|
}
|
}
|
|
/* Market List(固定高度 + 内部滚动,供 van-list 触底检测) */
|
.market-list {
|
padding: 2rem;
|
|
&.market-list--scroll {
|
overflow-y: auto;
|
max-height: calc(100vh - 18rem);
|
}
|
|
.pair-item {
|
display: flex;
|
flex-direction: column;
|
padding: 2rem;
|
margin-bottom: 2rem;
|
background: #fff;
|
border-radius: 1rem;
|
border: 0.1rem solid #f0f0f0;
|
cursor: pointer;
|
transition: background 0.2s ease;
|
box-shadow: 0rem .8rem 3.2rem 0rem rgba(0, 0, 0, .12);
|
|
&:hover {
|
background: #f9f9f9;
|
}
|
|
.pair-header {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
|
.pair-symbol {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 0.6rem;
|
font-size: 2rem;
|
font-weight: 600;
|
color: $text_color;
|
|
.pair-symbol-icon-wrap {
|
position: relative;
|
flex-shrink: 0;
|
}
|
|
.pair-symbol-icon {
|
width: 2.4rem;
|
height: 2.4rem;
|
border-radius: 50%;
|
// object-fit: contain;
|
|
&--large {
|
width: 3rem;
|
height: 3rem;
|
}
|
|
&--sm {
|
position: absolute;
|
right: -0.2rem;
|
bottom: -0.2rem;
|
width: 1.6rem;
|
height: 1.6rem;
|
border-radius: 50%;
|
object-fit: cover;
|
border: 0.15rem solid #fff;
|
box-shadow: 0 0 0.1rem rgba(0, 0, 0, 0.2);
|
}
|
}
|
}
|
|
.pair-change {
|
font-size: 1.75rem;
|
font-weight: 600;
|
|
&.up {
|
color: $green;
|
}
|
|
&.down {
|
color: $red;
|
}
|
}
|
}
|
|
.pair-bottom {
|
display: flex;
|
align-items: center;
|
gap: 1rem;
|
position: relative;
|
top: -2.2rem;
|
|
.action-price {
|
font-size: 2rem;
|
font-weight: 600;
|
}
|
|
.action-btn {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
padding: 0.3rem 5rem;
|
border: 0.1rem solid #333;
|
border-radius: 0.2rem;
|
font-size: 1.8rem;
|
background: #fff;
|
cursor: pointer;
|
min-width: 7rem;
|
gap: 0.5rem;
|
flex-shrink: 0;
|
|
.action-label {
|
font-size: 1.5rem;
|
}
|
|
&.sell {
|
.action-price {
|
color: $text_color;
|
}
|
|
.action-label {
|
color: $red;
|
}
|
}
|
|
&.buy {
|
.action-price {
|
color: $text_color;
|
}
|
|
.action-label {
|
color: $green;
|
}
|
}
|
|
&:hover {
|
background: #f9f9f9;
|
}
|
}
|
|
.pair-chart {
|
flex: 1;
|
height: 2.5rem;
|
margin: 0 0.5rem;
|
min-width: 6rem;
|
}
|
}
|
}
|
}
|
</style>
|