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