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 | 316 ++++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 266 insertions(+), 50 deletions(-)
diff --git a/src/views/trade/Options.vue b/src/views/trade/Options.vue
index bd36db6..934d5f0 100644
--- a/src/views/trade/Options.vue
+++ b/src/views/trade/Options.vue
@@ -40,25 +40,49 @@
</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">{{ 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,14 +94,14 @@
</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 { _collect, _deleteCollect } from '@/service/cryptos.api'
+import { _collect, _deleteCollect, _getCoins } from '@/service/cryptos.api'
import { useUserStore } from '@/store/user'
import { HOST_URL } from '@/config'
@@ -85,26 +109,41 @@
const { t } = useI18n()
const useStore = useUserStore()
-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 时的默认品种
// 从路由 path(params) 或 query 同步 symbol、activeTab
function applyFromRoute() {
@@ -120,6 +159,7 @@
}
}
+// 弹窗品种 Tab 配置(label 用于 i18n,value 对应接口 type)
const modalTabs = [
{ label: '加密货币', value: 'cryptos' },
{ label: '股票', value: 'US-stocks' },
@@ -138,7 +178,28 @@
// 嵌入合约:1=永续(合约交易),2=交割(期权交易)
const embedSelectIndex = computed(() => tradeType.value === 'contract' ? 1 : 2)
// 嵌入合约品种类型
+// 传给 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 ''
@@ -149,18 +210,68 @@
}
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,
+ 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()
}
@@ -175,7 +286,10 @@
return `${HOST_URL}/symbol/${baseSymbol.toLowerCase()}.png`
}
-// 获取交易数据;pageNo 页码,append 是否追加
+/**
+ * 拉取品种列表(浏览模式)
+ * 接口:publicRealtimeByType → hobi!getRealtime.action 补全行情
+ */
const fetchTradingData = async (pageNo = 1, append = false) => {
try {
const type = activeTab.value
@@ -188,17 +302,13 @@
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)
+ 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 {
- list = data.map(mapSymbolItem)
}
if (append) {
symbolList.value = [...symbolList.value, ...list]
@@ -219,14 +329,13 @@
}
}
-// 上拉触底加载更多(仅非自选 tab 分页,pageSize=10)
+// 上拉触底加载更多(仅浏览模式;搜索模式 item!list 一次返回全部)
const loadMoreSymbols = async () => {
- if (symbolInitialLoading.value) {
+ if (symbolInitialLoading.value || searchKeyword.value.trim()) {
symbolLoading.value = false
+ symbolFinished.value = true
return
}
- // if (symbolLoading.value || symbolFinished.value) return
- // symbolLoading.value = true
try {
await fetchTradingData(symbolPage.value, true)
symbolPage.value += 1
@@ -235,7 +344,7 @@
}
}
-// 获取数据
+/** Tab 切换或首次进入时重置分页并拉取第一页 */
const fetchData = async () => {
symbolPage.value = 1
symbolFinished.value = false
@@ -251,24 +360,111 @@
}
}
-// 过滤列表
-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)) ||
- (item.enName && item.enName.toLowerCase().includes(keyword))
- )
+ return searchResultList.value
})
// 选择交易对
const selectSymbol = (item) => {
currentSymbol.value = item.symbol
showSymbolModal.value = false
- searchKeyword.value = ''
+ clearSearchState()
}
function checkFavorite() {
if (!useStore.userInfo?.token || !currentSymbol.value) {
@@ -309,7 +505,11 @@
checkFavorite()
})
-// 监听路由 query 变化(同一页带不同 query 时同步)
+watch(searchKeyword, (val) => {
+ scheduleSearch(val)
+})
+
+// 路由 query 变化时同步 symbol、activeTab 并刷新列表
watch(() => route.query, () => {
applyFromRoute()
fetchData()
@@ -320,6 +520,14 @@
applyFromRoute()
fetchData()
checkFavorite()
+})
+
+onBeforeUnmount(() => {
+ // 组件卸载时清除搜索防抖定时器
+ if (searchDebounceTimer) {
+ clearTimeout(searchDebounceTimer)
+ searchDebounceTimer = null
+ }
})
</script>
@@ -484,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