10.10综合交易所原始源码_移动端
1
5 days ago c0d804868674ce54abaf85682134c70fbb225493
1
1 files modified
316 ■■■■ changed files
src/views/trade/Options.vue 316 ●●●● patch | view | raw | blame | history
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;