From c0d804868674ce54abaf85682134c70fbb225493 Mon Sep 17 00:00:00 2001
From: 李 <344137771@qq.com>
Date: Fri, 05 Jun 2026 18:26:07 +0800
Subject: [PATCH] 1
---
src/views/trade/Options.vue | 452 +++++++++++++++++++++++++++++++++++++++-----------------
1 files changed, 316 insertions(+), 136 deletions(-)
diff --git a/src/views/trade/Options.vue b/src/views/trade/Options.vue
index 6f9291a..934d5f0 100644
--- a/src/views/trade/Options.vue
+++ b/src/views/trade/Options.vue
@@ -33,32 +33,56 @@
close-icon-position="top-right">
<div class="symbol-modal">
<!-- Modal Tabs:同一 activeTab 的 label 与 value 统一配置 -->
- <div class="modal-tabs">
+ <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="activeTab = tab.value">
+ :class="{ active: activeTab === tab.value }" @click="switchTab(tab.value)">
{{ $t(tab.label) }}
</div>
</div>
- <!-- Search Bar -->
+ <!-- 搜索栏:股票/ETF/外汇走 item!list.action;加密货币本地过滤 -->
<div class="search-bar">
<van-icon name="search" size="20" class="search-icon" />
- <input type="text" :placeholder="$t('搜索交易种类')" v-model="searchKeyword" class="search-input" />
+ <input type="text" :placeholder="$t('搜索交易种类')" v-model="searchKeyword"
+ class="search-input" @input="handleSearchInput" />
</div>
- <!-- Symbol List(上拉加载,滚动容器需指定 scroll-target) -->
+ <!-- 品种列表:搜索模式用普通列表(避免 van-list 不刷新);无搜索词时用 van-list 分页 -->
<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)">
+ <!-- 搜索模式 -->
+ <template v-if="isSearchMode">
+ <div v-if="usesApiSearch && searchLoading" class="search-loading-tip">
+ {{ $t('加载中') || '加载中...' }}
+ </div>
+ <div class="symbol-item" v-for="(item, index) in displayList"
+ :key="`${listRenderKey}-${item.symbol}-${index}`" @click="selectSymbol(item)">
<div class="symbol-left">
<div class="symbol-info">
- <div class="symbol-name">{{ (item.symbol).toUpperCase() }}</div>
+ <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 class="symbol-change" :class="getChangeRatio(item) >= 0 ? 'up' : 'down'">
+ {{ getChangeRatio(item) >= 0 ? '+' : '' }}{{ getChangeRatio(item) }}%
+ </div>
+ <div class="symbol-price">{{ item.close }}</div>
+ </div>
+ <div v-if="!searchLoading && !displayList.length" class="search-empty-tip">
+ {{ $t('暂无数据') || '暂无数据' }}
+ </div>
+ </template>
+ <!-- 浏览模式:publicRealtimeByType 分页加载 -->
+ <van-list v-else v-model:loading="symbolLoading" :finished="symbolFinished"
+ :immediate-check="false" :scroll-target="symbolListRef"
+ :finished-text="$t('没有更多了') || '没有更多了'" @load="loadMoreSymbols">
+ <div class="symbol-item" v-for="(item, index) in displayList"
+ :key="`${item.symbol}-${index}`" @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="getChangeRatio(item) >= 0 ? 'up' : 'down'">
+ {{ getChangeRatio(item) >= 0 ? '+' : '' }}{{ getChangeRatio(item) }}%
</div>
<div class="symbol-price">{{ item.close }}</div>
</div>
@@ -70,63 +94,77 @@
</template>
<script setup>
-import { ref, computed, onMounted, watch, nextTick } from 'vue'
+import { ref, computed, onMounted, onBeforeUnmount, 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 { _collect, _deleteCollect, _getCoins } 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 tradeType = ref('options') // 顶部 Tab: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 activeTab = ref('cryptos') // 弹窗品种类型 Tab
+
+// ---------- 搜索相关 ----------
+const searchKeyword = ref('') // 搜索关键词
+const searchResultList = ref([]) // 股票/ETF/外汇 API 搜索结果
+const searchLoading = ref(false)
+const listRenderKey = ref(0) // 列表强制刷新 key,避免搜索后 DOM 不更新
+let searchDebounceTimer = null // 搜索防抖定时器
+let searchRequestId = 0 // 请求序号,丢弃过期的搜索响应
+
+// ---------- 品种列表分页 ----------
+const symbolList = ref([]) // 默认列表(publicRealtimeByType)
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 symbolInitialLoading = ref(false) // tab 切换首屏加载中,防止 van-list @load 重复请求
+
+// ---------- 常量配置 ----------
const SYMBOL_PAGE_SIZE = 10
+const SEARCH_DEBOUNCE_MS = 300 // 搜索防抖间隔(ms)
+const QUOTE_CHUNK_SIZE = 30 // hobi!getRealtime 单次请求 symbol 数量上限,避免 URL 过长只返回部分
+const API_SEARCH_TYPES = ['cryptos', 'US-stocks', 'indices', 'forex'] // 走 item!list.action 搜索的类型
+const TAB_TYPES = ['cryptos', 'US-stocks', 'indices', 'forex']
+const DEFAULT_SYMBOL = {
+ cryptos: 'btc',
+ 'US-stocks': 'AAPL',
+ indices: 'GlobalETF500',
+ forex: 'EURUSD',
+} // 切换 Tab 时的默认品种
-// 允许的 activeTab 值(与 modalTabs.value 一致)
-const VALID_ACTIVE_TABS = ['optional', 'forex', 'cryptos', 'US-stocks', 'indices']
-
-// 从路由 path(params) 或 query 同步 symbol 与 activeTab
+// 从路由 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
+ 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()
}
- if (tab != null && VALID_ACTIVE_TABS.includes(String(tab))) {
- activeTab.value = tab
- }
}
-// 弹窗 Tab:label 为多语言 key,value 与接口类型统一(optional 仅自选列表用)
+// 弹窗品种 Tab 配置(label 用于 i18n,value 对应接口 type)
const modalTabs = [
- // { label: '自选', value: 'optional' },
- { label: '外汇', value: 'forex' },
{ label: '加密货币', value: 'cryptos' },
{ label: '股票', value: 'US-stocks' },
- { label: 'ETF', value: 'indices' }
+ { label: 'ETF', value: 'indices' },
+ { label: '外汇', value: 'forex' },
]
// 头部显示:关联选择项的 name,无则用 symbol
@@ -139,10 +177,103 @@
// 嵌入合约:1=永续(合约交易),2=交割(期权交易)
const embedSelectIndex = computed(() => tradeType.value === 'contract' ? 1 : 2)
-// 嵌入合约品种类型(value 已与接口类型统一,仅 optional 需映射)
-const embedType = computed(() => {
- return activeTab.value === 'optional' ? 'forex' : activeTab.value
-})
+// 嵌入合约品种类型
+// 传给 OptionsContract 的品种类型,切换 Tab 时子组件会 SET_COIN_LIST → item!list.action
+const embedType = computed(() => activeTab.value)
+// 当前 Tab 是否走远程搜索(股票/ETF/外汇)
+const usesApiSearch = computed(() => API_SEARCH_TYPES.includes(activeTab.value))
+// 是否处于搜索模式(有搜索词即 true,切换为普通列表渲染)
+const isSearchMode = computed(() => !!searchKeyword.value.trim())
+
+/** 统一涨跌幅字段(接口可能返回 change_ratio 或 changeRatio) */
+function getChangeRatio(item) {
+ return item?.change_ratio ?? item?.changeRatio ?? 0
+}
+
+/** 将接口数据规范为列表展示结构 */
+function normalizeListItem(item) {
+ return {
+ symbol: item.symbol,
+ name: item.enName || item.name || item.symbol,
+ close: item.close ?? item.lastPrice ?? '--',
+ change_ratio: getChangeRatio(item),
+ type: item.type,
+ }
+}
+
+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 normalizeListItem(item)
+}
+
+/** 分批拉取行情,避免 symbol 过多时 getRealtime 只返回子集 */
+async function fetchQuotesBySymbols(symbols) {
+ const uniqueSymbols = [...new Set((symbols || []).filter(Boolean))]
+ if (!uniqueSymbols.length) return []
+ const chunks = []
+ for (let i = 0; i < uniqueSymbols.length; i += QUOTE_CHUNK_SIZE) {
+ chunks.push(uniqueSymbols.slice(i, i + QUOTE_CHUNK_SIZE).join(','))
+ }
+ const results = await Promise.all(
+ chunks.map(chunk => _getHomeList(chunk).catch(() => []))
+ )
+ return results.flat().filter(item => item && item.symbol)
+}
+
+/**
+ * 以 item!list / publicRealtimeByType 的完整列表为准,用行情数据补全价格与涨跌幅
+ * 不能直接用 homeData 替换,否则无行情的品种会被丢弃
+ */
+function mergeItemsWithQuotes(rawItems, quoteItems) {
+ const quoteMap = new Map()
+ ;(quoteItems || []).forEach(item => {
+ if (item?.symbol) {
+ quoteMap.set(String(item.symbol).toLowerCase(), item)
+ }
+ })
+ return (rawItems || []).map(raw => {
+ const quote = quoteMap.get(String(raw.symbol || '').toLowerCase())
+ return normalizeListItem(quote ? { ...raw, ...quote } : raw)
+ })
+}
+
+/** 递增 listRenderKey,强制列表重新渲染 */
+function bumpListRenderKey() {
+ listRenderKey.value += 1
+}
+
+/** 清空搜索状态(切换 Tab、选中品种、关闭弹窗时调用) */
+function clearSearchState() {
+ searchKeyword.value = ''
+ searchResultList.value = []
+ searchLoading.value = false
+ bumpListRenderKey()
+ if (searchDebounceTimer) {
+ clearTimeout(searchDebounceTimer)
+ searchDebounceTimer = null
+ }
+}
+
+/**
+ * 切换弹窗品种 Tab
+ * 1. 更新 activeTab、默认品种
+ * 2. 清空搜索
+ * 3. 重新拉取 publicRealtimeByType 第一页
+ */
+function switchTab(tab) {
+ if (activeTab.value === tab) return
+ activeTab.value = tab
+ currentSymbol.value = DEFAULT_SYMBOL[tab] || 'btc'
+ clearSearchState()
+ fetchData()
+}
// 图标路径
const starIcon = new URL('@/assets/image/icon-star.png', import.meta.url).href
@@ -155,78 +286,34 @@
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 是否追加
+/**
+ * 拉取品种列表(浏览模式)
+ * 接口:publicRealtimeByType → hobi!getRealtime.action 补全行情
+ */
const fetchTradingData = async (pageNo = 1, append = false) => {
- const type = activeTab.value === 'optional' ? 'forex' : activeTab.value
- const category = type === 'forex' ? 'forex' : null
-
try {
+ const type = activeTab.value
const params = {
- type: 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 = []
+ const symbols = data.map(item => item.symbol).filter(Boolean)
+ let list = data.map(mapSymbolItem)
+ if (symbols.length) {
+ const homeData = await fetchQuotesBySymbols(symbols)
+ if (homeData.length) {
+ list = mergeItemsWithQuotes(data, homeData)
}
- } else if (!append) {
- symbolList.value = []
+ }
+ if (append) {
+ symbolList.value = [...symbolList.value, ...list]
+ } else {
+ symbolList.value = list
}
if (data.length <= 0) {
symbolFinished.value = true
@@ -242,19 +329,13 @@
}
}
-// 上拉触底加载更多(仅非自选 tab 分页,pageSize=10)
+// 上拉触底加载更多(仅浏览模式;搜索模式 item!list 一次返回全部)
const loadMoreSymbols = async () => {
- if (activeTab.value === 'optional') {
+ if (symbolInitialLoading.value || searchKeyword.value.trim()) {
+ symbolLoading.value = false
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
@@ -263,19 +344,14 @@
}
}
-// 获取数据
+/** Tab 切换或首次进入时重置分页并拉取第一页 */
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
- }
+ await fetchTradingData(1, false)
+ symbolPage.value = 2
// tab 切换后列表滚动回顶部
await nextTick()
if (symbolListRef.value) symbolListRef.value.scrollTop = 0
@@ -284,26 +360,112 @@
}
}
-// 过滤列表
-const filteredList = computed(() => {
- if (!searchKeyword.value) {
+/**
+ * 远程搜索(股票/ETF/外汇)
+ * 接口:item!list.action(_getCoins)
+ * 1. 优先 { type, name } 查询
+ * 2. 无结果则 { name } 查询后按 type 过滤
+ * 3. 再用 hobi!getRealtime.action 补全价格与涨跌幅
+ */
+async function fetchSearchList(keyword) {
+ const type = activeTab.value
+ const requestId = ++searchRequestId
+ searchLoading.value = true
+ try {
+ let raw = []
+ try {
+ const res = await _getCoins({ type, name: keyword.trim() })
+ raw = Array.isArray(res) ? res : []
+ } catch (e) {
+ raw = []
+ }
+ if (!raw.length) {
+ const res = await _getCoins({ name: keyword.trim() })
+ raw = (Array.isArray(res) ? res : []).filter(item => item.type === type)
+ }
+ if (requestId !== searchRequestId) return // 已有更新的搜索请求,丢弃本次结果
+ if (!raw.length) {
+ searchResultList.value = []
+ bumpListRenderKey()
+ return
+ }
+ if (requestId !== searchRequestId) return // 已有更新的搜索请求,丢弃本次结果
+ const symbols = raw.map(item => item.symbol).filter(Boolean)
+ let list = raw.map(mapSymbolItem)
+ if (symbols.length) {
+ const homeData = await fetchQuotesBySymbols(symbols)
+ if (requestId !== searchRequestId) return // 已有更新的搜索请求,丢弃本次结果
+ if (homeData.length) {
+ list = mergeItemsWithQuotes(raw, homeData)
+ }
+ }
+ searchResultList.value = list
+ bumpListRenderKey()
+ } catch (error) {
+ if (requestId !== searchRequestId) return // 已有更新的搜索请求,丢弃本次结果
+ console.error('搜索交易品种失败:', error)
+ searchResultList.value = []
+ bumpListRenderKey()
+ } finally {
+ if (requestId === searchRequestId) {
+ searchLoading.value = false
+ }
+ }
+}
+
+/** 搜索框 input 事件,与 watch(searchKeyword) 配合触发防抖搜索 */
+function handleSearchInput(e) {
+ const val = e?.target?.value ?? searchKeyword.value
+ scheduleSearch(val)
+}
+
+/**
+ * 调度搜索(300ms 防抖)
+ * 所有 Tab 均走 item!list.action;行情仅用于补全价格,不裁剪列表
+ */
+function scheduleSearch(keyword) {
+ if (searchDebounceTimer) {
+ clearTimeout(searchDebounceTimer)
+ searchDebounceTimer = null
+ }
+ bumpListRenderKey()
+ const trimmed = (keyword ?? '').trim()
+ if (!trimmed) {
+ searchRequestId += 1
+ searchResultList.value = []
+ searchLoading.value = false
+ symbolFinished.value = false
+ return
+ }
+ searchResultList.value = []
+ searchLoading.value = true
+ // 搜索模式不用 van-list 分页,item!list 一次返回全部匹配项
+ symbolFinished.value = true
+ searchDebounceTimer = setTimeout(() => {
+ searchDebounceTimer = null
+ fetchSearchList(trimmed)
+ }, SEARCH_DEBOUNCE_MS)
+}
+
+/**
+ * 最终展示列表
+ * - 无关键词:symbolList(publicRealtimeByType 分页浏览,支持上拉加载)
+ * - 有关键词:searchResultList(item!list.action 全量返回,容器内滚动)
+ */
+const displayList = computed(() => {
+ const keyword = searchKeyword.value.trim()
+ if (!keyword) {
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))
- )
+ return searchResultList.value
})
// 选择交易对
const selectSymbol = (item) => {
currentSymbol.value = item.symbol
showSymbolModal.value = false
- searchKeyword.value = ''
+ clearSearchState()
}
-
-// 查询当前交易对是否已收藏(与 add-currency / trade-head 一致)
function checkFavorite() {
if (!useStore.userInfo?.token || !currentSymbol.value) {
isFavorite.value = false
@@ -338,24 +500,34 @@
}
}
-// 监听 tab 切换
-watch(activeTab, () => {
- fetchData()
-})
-
// 当前交易对变化时刷新收藏状态
watch(currentSymbol, () => {
checkFavorite()
})
-// 监听路由 query 变化(同一页带不同 query 时同步)
-watch(() => route.query, () => applyFromRoute(), { deep: true })
+watch(searchKeyword, (val) => {
+ scheduleSearch(val)
+})
+
+// 路由 query 变化时同步 symbol、activeTab 并刷新列表
+watch(() => route.query, () => {
+ applyFromRoute()
+ fetchData()
+}, { deep: true })
// 组件挂载时:先从 path/query 取 symbol、activeTab,再拉数据并刷新收藏状态
onMounted(() => {
applyFromRoute()
fetchData()
checkFavorite()
+})
+
+onBeforeUnmount(() => {
+ // 组件卸载时清除搜索防抖定时器
+ if (searchDebounceTimer) {
+ clearTimeout(searchDebounceTimer)
+ searchDebounceTimer = null
+ }
})
</script>
@@ -520,6 +692,14 @@
overflow-y: auto;
padding: 2rem 2rem 0;
+ .search-loading-tip,
+ .search-empty-tip {
+ text-align: center;
+ font-size: 2rem;
+ color: $text_color1;
+ padding: 3rem 1rem;
+ }
+
.symbol-item {
display: flex;
align-items: center;
--
Gitblit v1.9.3