| | |
| | | </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> |
| | |
| | | </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' |
| | | |
| | |
| | | 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() { |
| | |
| | | } |
| | | } |
| | | |
| | | // 弹窗品种 Tab 配置(label 用于 i18n,value 对应接口 type) |
| | | const modalTabs = [ |
| | | { label: '加密货币', value: 'cryptos' }, |
| | | { label: '股票', value: 'US-stocks' }, |
| | |
| | | // 嵌入合约: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 '' |
| | |
| | | } |
| | | |
| | | 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() |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | 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] |
| | |
| | | } |
| | | } |
| | | |
| | | // 上拉触底加载更多(仅非自选 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 |
| | |
| | | } |
| | | } |
| | | |
| | | // 获取数据 |
| | | /** Tab 切换或首次进入时重置分页并拉取第一页 */ |
| | | const fetchData = async () => { |
| | | symbolPage.value = 1 |
| | | symbolFinished.value = false |
| | |
| | | } |
| | | } |
| | | |
| | | // 过滤列表 |
| | | 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) { |
| | |
| | | checkFavorite() |
| | | }) |
| | | |
| | | // 监听路由 query 变化(同一页带不同 query 时同步) |
| | | watch(searchKeyword, (val) => { |
| | | scheduleSearch(val) |
| | | }) |
| | | |
| | | // 路由 query 变化时同步 symbol、activeTab 并刷新列表 |
| | | watch(() => route.query, () => { |
| | | applyFromRoute() |
| | | fetchData() |
| | |
| | | applyFromRoute() |
| | | fetchData() |
| | | checkFavorite() |
| | | }) |
| | | |
| | | onBeforeUnmount(() => { |
| | | // 组件卸载时清除搜索防抖定时器 |
| | | if (searchDebounceTimer) { |
| | | clearTimeout(searchDebounceTimer) |
| | | searchDebounceTimer = null |
| | | } |
| | | }) |
| | | </script> |
| | | |
| | |
| | | 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; |