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