<template>
|
<div class="options-trade-page">
|
<!-- Header -->
|
<div class="trade-header">
|
<div class="header-tabs">
|
<button class="tab-btn" :class="{ active: tradeType === 'options' }" @click="tradeType = 'options'">
|
{{ $t('期权交易') }}
|
</button>
|
<button class="tab-btn" :class="{ active: tradeType === 'contract' }" @click="tradeType = 'contract'">
|
{{ $t('合约交易') }}
|
</button>
|
</div>
|
|
<div class="symbol-row">
|
<div class="symbol-selector" @click="showSymbolModal = true">
|
<span class="symbol-text">{{ currentSymbolName }}</span>
|
<img src="@/assets/image/icon-more.png" alt="arrow" class="down-icon" />
|
</div>
|
<!-- <div class="favorite-icon" @click="toggleFavorite">
|
<img :src="isFavorite ? starActiveIcon : starIcon" alt="favorite" />
|
</div> -->
|
</div>
|
</div>
|
|
<!-- Main Content -->
|
<div class="trade-content">
|
<OptionsContract v-if="currentSymbol" :symbol="currentSymbol" :symbol-display-name="currentSymbolName"
|
:select-index="embedSelectIndex" :type="embedType" @update-symbol="currentSymbol = $event" />
|
</div>
|
|
<!-- Symbol Selection Modal -->
|
<van-popup v-model:show="showSymbolModal" round position="bottom" :style="{ height: '80%' }" closeable
|
close-icon-position="top-right">
|
<div class="symbol-modal">
|
<!-- Modal Tabs:同一 activeTab 的 label 与 value 统一配置 -->
|
<div v-if="modalTabs.length > 1" class="modal-tabs">
|
<div class="tab-item" v-for="tab in modalTabs" :key="tab.value"
|
:class="{ active: activeTab === tab.value }" @click="switchTab(tab.value)">
|
{{ $t(tab.label) }}
|
</div>
|
</div>
|
|
<!-- Search Bar -->
|
<div class="search-bar">
|
<van-icon name="search" size="20" class="search-icon" />
|
<input type="text" :placeholder="$t('搜索交易种类')" v-model="searchKeyword" class="search-input" />
|
</div>
|
|
<!-- Symbol List(上拉加载,滚动容器需指定 scroll-target) -->
|
<div ref="symbolListRef" class="symbol-list">
|
<van-list v-model:loading="symbolLoading" :finished="symbolFinished" :immediate-check="false"
|
:scroll-target="symbolListRef" :finished-text="$t('没有更多了') || '没有更多了'" @load="loadMoreSymbols">
|
<div class="symbol-item" v-for="item in filteredList" :key="item.symbol"
|
@click="selectSymbol(item)">
|
<div class="symbol-left">
|
<div class="symbol-info">
|
<div class="symbol-name">{{ getSymbolDisplayName(item) }}</div>
|
</div>
|
</div>
|
<div class="symbol-change" :class="item.change_ratio >= 0 ? 'up' : 'down'">
|
{{ item.change_ratio >= 0 ? '+' : '' }}{{ item.change_ratio }}%
|
</div>
|
<div class="symbol-price">{{ item.close }}</div>
|
</div>
|
</van-list>
|
</div>
|
</div>
|
</van-popup>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
import { useRoute } from 'vue-router'
|
import { useI18n } from 'vue-i18n'
|
import { Popup, Icon, showToast, List as VanList } from 'vant'
|
import OptionsContract from './components/OptionsContract.vue'
|
import { _getHomeList } from '@/service/home.api'
|
import { _getRealtimeByType, _isItemHasAddGlobal } from '@/service/quotes.api'
|
import { _collect, _deleteCollect } from '@/service/cryptos.api'
|
import { useUserStore } from '@/store/user'
|
import { HOST_URL } from '@/config'
|
|
const route = useRoute()
|
const { t } = useI18n()
|
const useStore = useUserStore()
|
|
const tradeType = ref('options') // 'options' 期权 / 'contract' 合约,默认期权交易
|
const currentSymbol = ref('btc')
|
const isFavorite = ref(false)
|
const showSymbolModal = ref(false)
|
const activeTab = ref('cryptos')
|
const searchKeyword = ref('')
|
const symbolList = ref([])
|
const symbolPage = ref(1)
|
const symbolLoading = ref(false)
|
const symbolFinished = ref(false)
|
const symbolListRef = ref(null)
|
const symbolInitialLoading = ref(false) // tab 切换时首屏请求中,避免 @load 重复请求 pageNo=1
|
const SYMBOL_PAGE_SIZE = 10
|
const TAB_TYPES = ['cryptos', 'US-stocks', 'indices', 'forex']
|
const DEFAULT_SYMBOL = {
|
cryptos: 'btc',
|
'US-stocks': 'AAPL',
|
indices: 'GlobalETF500',
|
forex: 'EURUSD',
|
}
|
|
// 从路由 path(params) 或 query 同步 symbol、activeTab
|
function applyFromRoute() {
|
const p = route.params || {}
|
const q = route.query || {}
|
const symbol = q.symbol ?? p.symbol
|
const tab = q.activeTab ?? p.activeTab
|
if (tab && TAB_TYPES.includes(String(tab))) {
|
activeTab.value = String(tab)
|
}
|
if (symbol != null && String(symbol).trim()) {
|
currentSymbol.value = String(symbol).trim()
|
}
|
}
|
|
const modalTabs = [
|
{ label: '加密货币', value: 'cryptos' },
|
{ label: '股票', value: 'US-stocks' },
|
{ label: 'ETF', value: 'indices' },
|
{ label: '外汇', value: 'forex' },
|
]
|
|
// 头部显示:关联选择项的 name,无则用 symbol
|
const currentSymbolName = computed(() => {
|
const sym = currentSymbol.value
|
if (!sym) return ''
|
const item = symbolList.value.find(i => (i.symbol || '').toLowerCase() === sym.toLowerCase())
|
return (item && item.name) ? item.name : sym
|
})
|
|
// 嵌入合约:1=永续(合约交易),2=交割(期权交易)
|
const embedSelectIndex = computed(() => tradeType.value === 'contract' ? 1 : 2)
|
// 嵌入合约品种类型
|
const embedType = computed(() => activeTab.value)
|
|
function getSymbolDisplayName(item) {
|
if (!item) return ''
|
const name = item.name || item.enName || item.symbol || ''
|
if (activeTab.value === 'forex') return String(name).toUpperCase()
|
if (activeTab.value === 'cryptos') return String(item.symbol || name).toUpperCase()
|
return name
|
}
|
|
function mapSymbolItem(item) {
|
return {
|
symbol: item.symbol,
|
name: item.enName || item.name || item.symbol,
|
close: item.close ?? item.lastPrice ?? '--',
|
change_ratio: item.changeRatio ?? item.change_ratio ?? 0,
|
}
|
}
|
|
function switchTab(tab) {
|
if (activeTab.value === tab) return
|
activeTab.value = tab
|
currentSymbol.value = DEFAULT_SYMBOL[tab] || 'btc'
|
fetchData()
|
}
|
|
// 图标路径
|
const starIcon = new URL('@/assets/image/icon-star.png', import.meta.url).href
|
const starActiveIcon = new URL('@/assets/image/icon-star_active.png', import.meta.url).href
|
|
// 获取交易对图标
|
const getSymbolIcon = (symbol) => {
|
if (!symbol) return ''
|
const baseSymbol = symbol.split('/')[0] || symbol.split('USDT')[0] || symbol
|
return `${HOST_URL}/symbol/${baseSymbol.toLowerCase()}.png`
|
}
|
|
// 获取交易数据;pageNo 页码,append 是否追加
|
const fetchTradingData = async (pageNo = 1, append = false) => {
|
try {
|
const type = activeTab.value
|
const params = {
|
type,
|
pageNo: pageNo,
|
pageSize: SYMBOL_PAGE_SIZE
|
}
|
|
const data = await _getRealtimeByType(params)
|
|
if (data && Array.isArray(data)) {
|
const symbols = data.map(item => item.symbol).join(',')
|
let list = []
|
if (symbols) {
|
const homeData = await _getHomeList(symbols)
|
if (homeData && Array.isArray(homeData) && homeData.length) {
|
list = homeData
|
} else {
|
list = data.map(mapSymbolItem)
|
}
|
} else {
|
list = data.map(mapSymbolItem)
|
}
|
if (append) {
|
symbolList.value = [...symbolList.value, ...list]
|
} else {
|
symbolList.value = list
|
}
|
if (data.length <= 0) {
|
symbolFinished.value = true
|
}
|
} else {
|
if (!append) symbolList.value = []
|
symbolFinished.value = true
|
}
|
} catch (error) {
|
console.error('获取交易数据失败:', error)
|
if (!append) symbolList.value = []
|
symbolFinished.value = true
|
}
|
}
|
|
// 上拉触底加载更多(仅非自选 tab 分页,pageSize=10)
|
const loadMoreSymbols = async () => {
|
if (symbolInitialLoading.value) {
|
symbolLoading.value = false
|
return
|
}
|
// if (symbolLoading.value || symbolFinished.value) return
|
// symbolLoading.value = true
|
try {
|
await fetchTradingData(symbolPage.value, true)
|
symbolPage.value += 1
|
} finally {
|
symbolLoading.value = false
|
}
|
}
|
|
// 获取数据
|
const fetchData = async () => {
|
symbolPage.value = 1
|
symbolFinished.value = false
|
symbolInitialLoading.value = true
|
try {
|
await fetchTradingData(1, false)
|
symbolPage.value = 2
|
// tab 切换后列表滚动回顶部
|
await nextTick()
|
if (symbolListRef.value) symbolListRef.value.scrollTop = 0
|
} finally {
|
symbolInitialLoading.value = false
|
}
|
}
|
|
// 过滤列表
|
const filteredList = computed(() => {
|
if (!searchKeyword.value) {
|
return symbolList.value
|
}
|
const keyword = searchKeyword.value.toLowerCase()
|
return symbolList.value.filter(item =>
|
item.symbol.toLowerCase().includes(keyword) ||
|
(item.name && item.name.toLowerCase().includes(keyword)) ||
|
(item.enName && item.enName.toLowerCase().includes(keyword))
|
)
|
})
|
|
// 选择交易对
|
const selectSymbol = (item) => {
|
currentSymbol.value = item.symbol
|
showSymbolModal.value = false
|
searchKeyword.value = ''
|
}
|
function checkFavorite() {
|
if (!useStore.userInfo?.token || !currentSymbol.value) {
|
isFavorite.value = false
|
return
|
}
|
_isItemHasAddGlobal({ symbol: currentSymbol.value }).then((data) => {
|
isFavorite.value = !!data
|
}).catch(() => {
|
isFavorite.value = false
|
})
|
}
|
|
// 切换收藏:与 add-currency 一致,使用 _collect / _deleteCollect(默认自选列表)
|
async function toggleFavorite() {
|
if (!currentSymbol.value) return
|
if (!useStore.userInfo?.token) {
|
showToast(t('请先登录'))
|
return
|
}
|
try {
|
if (isFavorite.value) {
|
await _deleteCollect(currentSymbol.value)
|
isFavorite.value = false
|
showToast(t('successfullyDeleted'))
|
} else {
|
await _collect(currentSymbol.value)
|
isFavorite.value = true
|
showToast(t('添加成功'))
|
}
|
} catch (e) {
|
showToast(e?.msg || t('操作失败'))
|
}
|
}
|
|
// 当前交易对变化时刷新收藏状态
|
watch(currentSymbol, () => {
|
checkFavorite()
|
})
|
|
// 监听路由 query 变化(同一页带不同 query 时同步)
|
watch(() => route.query, () => {
|
applyFromRoute()
|
fetchData()
|
}, { deep: true })
|
|
// 组件挂载时:先从 path/query 取 symbol、activeTab,再拉数据并刷新收藏状态
|
onMounted(() => {
|
applyFromRoute()
|
fetchData()
|
checkFavorite()
|
})
|
</script>
|
|
<style lang="scss" scoped>
|
.options-trade-page {
|
min-height: 100vh;
|
background: #fff;
|
}
|
|
/* Header */
|
.trade-header {
|
padding: 2rem;
|
background: #fff;
|
border-bottom: 0.1rem solid #f0f0f0;
|
|
.header-tabs {
|
display: flex;
|
background: #fff;
|
border: 0.1rem solid #e0e0e0;
|
border-radius: 1.2rem;
|
padding: 0.4rem;
|
gap: 0.2rem;
|
|
.tab-btn {
|
flex: 1;
|
padding: 1rem 2rem;
|
font-size: 2rem;
|
border: none;
|
border-radius: 0.8rem;
|
background: transparent;
|
color: $text_color;
|
cursor: pointer;
|
transition: all 0.3s ease;
|
|
&.active {
|
background: #0a6bfa;
|
color: #fff;
|
font-weight: 600;
|
}
|
}
|
}
|
|
.symbol-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 1rem;
|
margin-top: 2.5rem;
|
}
|
|
.symbol-selector {
|
display: flex;
|
align-items: center;
|
gap: 0.5rem;
|
cursor: pointer;
|
|
.symbol-text {
|
font-size: 4rem;
|
color: $text_color;
|
}
|
|
.down-icon {
|
width: 2rem;
|
margin-left: .5rem;
|
}
|
}
|
|
.favorite-icon {
|
width: 2.4rem;
|
height: 2.4rem;
|
flex-shrink: 0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
|
img {
|
width: 100%;
|
height: 100%;
|
}
|
}
|
}
|
|
/* Trade Content */
|
.trade-content {
|
padding: 2rem;
|
}
|
|
/* Symbol Modal */
|
.symbol-modal {
|
padding: 2rem 0;
|
height: 100%;
|
display: flex;
|
flex-direction: column;
|
|
/* 与 Market.vue 的 market-tabs 样式一致 */
|
.modal-tabs {
|
display: flex;
|
border: 0.1rem solid #e0e0e0;
|
background: #fff;
|
border-radius: 1rem;
|
padding: 0.3rem;
|
overflow-x: auto;
|
gap: 0.3rem;
|
margin-bottom: 2rem;
|
margin-top: 4rem;
|
margin: 4rem 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;
|
}
|
}
|
}
|
|
.search-bar {
|
display: flex;
|
align-items: center;
|
gap: 1rem;
|
padding: 1.5rem;
|
background: #fff;
|
border-radius: 1rem;
|
margin: 0 2rem 2rem;
|
border: 1px solid #000;
|
|
.search-icon {
|
width: 2rem;
|
height: 2rem;
|
}
|
|
.search-input {
|
flex: 1;
|
border: none;
|
background: transparent;
|
font-size: 2.4rem;
|
color: $text_color;
|
outline: none;
|
|
&::placeholder {
|
color: $text_color1;
|
}
|
}
|
}
|
|
.symbol-list {
|
flex: 1;
|
overflow-y: auto;
|
padding: 2rem 2rem 0;
|
|
.symbol-item {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 2rem 1rem;
|
background: #fff;
|
border-radius: 1.5rem;
|
border: 0.1rem solid #f0f0f0;
|
margin-bottom: 1rem;
|
cursor: pointer;
|
transition: background 0.2s ease;
|
box-shadow: 0rem .24rem 1.7rem 0rem rgba(0, 0, 0, .22);
|
font-size: 2rem;
|
|
&:hover {
|
background: #f9f9f9;
|
}
|
|
.symbol-left {
|
display: flex;
|
align-items: center;
|
gap: 1.5rem;
|
width: 33%;
|
|
.symbol-icon {
|
width: 4rem;
|
height: 4rem;
|
border-radius: 50%;
|
overflow: hidden;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background: #f5f5f5;
|
|
img {
|
width: 100%;
|
height: 100%;
|
object-fit: cover;
|
}
|
}
|
|
.symbol-info {
|
.symbol-name {
|
font-weight: 600;
|
color: $text_color;
|
}
|
}
|
}
|
|
|
.symbol-change {
|
font-weight: 600;
|
|
&.up {
|
color: $green;
|
}
|
|
&.down {
|
color: $red;
|
}
|
}
|
|
.symbol-price {
|
font-weight: 600;
|
color: $text_color;
|
width: 33%;
|
text-align: right;
|
}
|
}
|
}
|
}
|
</style>
|