<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 class="modal-tabs">
|
<div class="tab-item" v-for="tab in modalTabs" :key="tab.value"
|
:class="{ active: activeTab === tab.value }" @click="activeTab = 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">{{ (item.symbol).toUpperCase() }}</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 { _listItemsById, _itemUserOptionalList, _getQuotes } from '@/service/quotes.api'
|
import { _collect, _deleteCollect } from '@/service/cryptos.api'
|
import { useUserStore } from '@/store/user'
|
import { useQuotesStore } from '@/store/quotes.store'
|
import { OPCIONA_LIST } from '@/store/types.store'
|
import { HOST_URL } from '@/config'
|
|
const route = useRoute()
|
const { t } = useI18n()
|
const useStore = useUserStore()
|
const quotesStore = useQuotesStore()
|
|
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
|
|
// 允许的 activeTab 值(与 modalTabs.value 一致)
|
const VALID_ACTIVE_TABS = ['optional', 'forex', 'cryptos', 'US-stocks', 'indices']
|
|
// 从路由 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 ?? q.tab ?? p.activeTab ?? p.tab
|
if (symbol != null && String(symbol).trim()) {
|
currentSymbol.value = String(symbol).trim()
|
}
|
if (tab != null && VALID_ACTIVE_TABS.includes(String(tab))) {
|
activeTab.value = tab
|
}
|
}
|
|
// 弹窗 Tab:label 为多语言 key,value 与接口类型统一(optional 仅自选列表用)
|
const modalTabs = [
|
// { label: '自选', value: 'optional' },
|
{ label: '外汇', value: 'forex' },
|
{ label: '加密货币', value: 'cryptos' },
|
{ label: '股票', value: 'US-stocks' },
|
{ label: 'ETF', value: 'indices' }
|
]
|
|
// 头部显示:关联选择项的 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)
|
// 嵌入合约品种类型(value 已与接口类型统一,仅 optional 需映射)
|
const embedType = computed(() => {
|
return activeTab.value === 'optional' ? 'forex' : activeTab.value
|
})
|
|
// 图标路径
|
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`
|
}
|
|
// 获取自选数据
|
const fetchOptionalData = async () => {
|
try {
|
if (!useStore.userInfo.token) {
|
symbolList.value = []
|
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)) {
|
const symbols = itemsData.map(item => ({ symbol: item.symbol }))
|
quotesStore[OPCIONA_LIST](symbols)
|
|
if (symbols.length > 0) {
|
const symbolStr = symbols.map(s => s.symbol).join(',')
|
const data = await _getHomeList(symbolStr)
|
if (data && Array.isArray(data)) {
|
symbolList.value = data
|
} else {
|
symbolList.value = []
|
}
|
} else {
|
symbolList.value = []
|
}
|
} else {
|
symbolList.value = []
|
}
|
} else {
|
symbolList.value = []
|
}
|
} catch (error) {
|
console.error('获取自选数据失败:', error)
|
symbolList.value = []
|
}
|
}
|
|
// 获取交易数据(activeTab.value 已与接口 type 统一);pageNo 页码,append 是否追加
|
const fetchTradingData = async (pageNo = 1, append = false) => {
|
const type = activeTab.value === 'optional' ? 'forex' : activeTab.value
|
const category = type === 'forex' ? 'forex' : null
|
|
try {
|
const params = {
|
type: type,
|
pageNo: pageNo,
|
pageSize: SYMBOL_PAGE_SIZE
|
}
|
if (category) {
|
params.category = category
|
}
|
|
const data = await _getRealtimeByType(params)
|
|
if (data && Array.isArray(data)) {
|
const symbols = data.map(item => item.symbol).join(',')
|
if (symbols) {
|
const homeData = await _getHomeList(symbols)
|
if (homeData && Array.isArray(homeData)) {
|
if (append) {
|
symbolList.value = [...symbolList.value, ...homeData]
|
} else {
|
symbolList.value = homeData
|
}
|
} else if (!append) {
|
symbolList.value = []
|
}
|
} else if (!append) {
|
symbolList.value = []
|
}
|
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 (activeTab.value === 'optional') {
|
symbolFinished.value = true
|
symbolLoading.value = false
|
return
|
}
|
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 {
|
if (activeTab.value === 'optional') {
|
await fetchOptionalData()
|
symbolFinished.value = true
|
} else {
|
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))
|
)
|
})
|
|
// 选择交易对
|
const selectSymbol = (item) => {
|
currentSymbol.value = item.symbol
|
showSymbolModal.value = false
|
searchKeyword.value = ''
|
}
|
|
// 查询当前交易对是否已收藏(与 add-currency / trade-head 一致)
|
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('操作失败'))
|
}
|
}
|
|
// 监听 tab 切换
|
watch(activeTab, () => {
|
fetchData()
|
})
|
|
// 当前交易对变化时刷新收藏状态
|
watch(currentSymbol, () => {
|
checkFavorite()
|
})
|
|
// 监听路由 query 变化(同一页带不同 query 时同步)
|
watch(() => route.query, () => applyFromRoute(), { 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>
|