ws连接自动连接与断开
This commit is contained in:
parent
496e5e9e6e
commit
8fe2373b2d
10
src/App.vue
10
src/App.vue
@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<AppHeader v-if="quote" :quote="quote" />
|
||||
<AppHeader
|
||||
v-if="quote"
|
||||
:quote="quote"
|
||||
:ws-status="wsStatus"
|
||||
:ws-in-session="wsInSession"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<section class="quote-section">
|
||||
@ -64,6 +69,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import AppLayout from './layout/AppLayout.vue'
|
||||
import AppHeader from './components/header/AppHeader.vue'
|
||||
import QuoteStats from './components/quote/QuoteStats.vue'
|
||||
@ -79,8 +85,10 @@ import { useQuote } from './composables/useQuote'
|
||||
import { useNews } from './composables/useNews'
|
||||
import { usePositions } from './composables/usePositions'
|
||||
import { useAiAdvice } from './composables/useAiAdvice'
|
||||
import { useQuotaStore } from './stores/quota'
|
||||
|
||||
const { data: quote, klineLoading, loadKline } = useQuote()
|
||||
const { wsStatus, wsInSession } = storeToRefs(useQuotaStore())
|
||||
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
|
||||
const { data: positions } = usePositions()
|
||||
const { data: advice, loading: adviceLoading, refresh: refreshAdvice } = useAiAdvice()
|
||||
|
||||
@ -16,9 +16,13 @@ function resolveWsUrl(): string {
|
||||
return `${proto}//${host}/finance-ws/`
|
||||
}
|
||||
|
||||
/** UI / store 用的连接状态 */
|
||||
export type WsConnectionStatus = 'disconnected' | 'connecting' | 'connected'
|
||||
|
||||
export type BaiduQuoteWsHandlers = {
|
||||
onMessage: (msg: BaiduWsMessage) => void
|
||||
onError?: (err: unknown) => void
|
||||
onStatus?: (status: WsConnectionStatus) => void
|
||||
}
|
||||
|
||||
function buildItem(): BaiduWsItem {
|
||||
@ -41,13 +45,26 @@ export class BaiduQuoteWs {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempt = 0
|
||||
private intentionalClose = false
|
||||
/** True from connect() until intentional disconnect(); survives transient closes. */
|
||||
private running = false
|
||||
private status: WsConnectionStatus = 'disconnected'
|
||||
private handlers: BaiduQuoteWsHandlers
|
||||
|
||||
constructor(handlers: BaiduQuoteWsHandlers) {
|
||||
this.handlers = handlers
|
||||
}
|
||||
|
||||
/** Whether this client should stay connected (including reconnect backoff). */
|
||||
isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
getStatus(): WsConnectionStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
this.running = true
|
||||
this.intentionalClose = false
|
||||
this.clearReconnect()
|
||||
if (
|
||||
@ -60,6 +77,7 @@ export class BaiduQuoteWs {
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.running = false
|
||||
this.intentionalClose = true
|
||||
this.clearTimers()
|
||||
this.clearReconnect()
|
||||
@ -75,11 +93,19 @@ export class BaiduQuoteWs {
|
||||
}
|
||||
this.ws = null
|
||||
}
|
||||
this.setStatus('disconnected')
|
||||
}
|
||||
|
||||
private setStatus(next: WsConnectionStatus): void {
|
||||
if (this.status === next) return
|
||||
this.status = next
|
||||
this.handlers.onStatus?.(next)
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
if (this.intentionalClose) return
|
||||
|
||||
this.setStatus('connecting')
|
||||
const ws = new WebSocket(resolveWsUrl())
|
||||
this.ws = ws
|
||||
|
||||
@ -93,6 +119,7 @@ export class BaiduQuoteWs {
|
||||
return
|
||||
}
|
||||
this.reconnectAttempt = 0
|
||||
this.setStatus('connected')
|
||||
this.sendSubscribe()
|
||||
this.startTimers()
|
||||
}
|
||||
@ -115,7 +142,12 @@ export class BaiduQuoteWs {
|
||||
ws.onclose = () => {
|
||||
this.clearTimers()
|
||||
this.ws = null
|
||||
if (!this.intentionalClose) this.scheduleReconnect()
|
||||
if (!this.intentionalClose) {
|
||||
this.setStatus('connecting')
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.setStatus('disconnected')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,10 @@
|
||||
<span class="code">{{ quote.code }}</span>
|
||||
<span class="exchange">{{ quote.exchange }}</span>
|
||||
<el-tag size="small" type="info" effect="plain">{{ quote.status }}</el-tag>
|
||||
<span class="ws-status" :class="wsStatusClass" :title="wsStatusTitle">
|
||||
<span class="ws-dot" aria-hidden="true" />
|
||||
<span class="ws-label">{{ wsStatusLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="price-block">
|
||||
<span class="last" :class="priceClass">{{ quote.last.toFixed(2) }}</span>
|
||||
@ -25,10 +29,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
import type { QuoteData } from '../../types'
|
||||
import type { WsConnectionStatus } from '../../api/baidu/ws'
|
||||
import SettingsDialog from './SettingsDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
quote: QuoteData
|
||||
wsStatus: WsConnectionStatus
|
||||
wsInSession: boolean
|
||||
}>()
|
||||
|
||||
const settingsVisible = ref(false)
|
||||
@ -37,6 +44,27 @@ const priceClass = computed(() =>
|
||||
props.quote.change >= 0 ? 'price-up' : 'price-down',
|
||||
)
|
||||
|
||||
const wsStatusLabel = computed(() => {
|
||||
if (!props.wsInSession) return '休市'
|
||||
if (props.wsStatus === 'connected') return '已连接'
|
||||
if (props.wsStatus === 'connecting') return '连接中'
|
||||
return '未连接'
|
||||
})
|
||||
|
||||
const wsStatusClass = computed(() => {
|
||||
if (!props.wsInSession) return 'is-off'
|
||||
if (props.wsStatus === 'connected') return 'is-on'
|
||||
if (props.wsStatus === 'connecting') return 'is-pending'
|
||||
return 'is-off'
|
||||
})
|
||||
|
||||
const wsStatusTitle = computed(() => {
|
||||
if (!props.wsInSession) {
|
||||
return '非交易时段(09:00–11:30 / 13:30–15:30 / 21:00–23:00)'
|
||||
}
|
||||
return `WebSocket ${wsStatusLabel.value}`
|
||||
})
|
||||
|
||||
function formatSigned(n: number) {
|
||||
if (n > 0) return `+${Number(n).toFixed(2)}`
|
||||
return Number(n).toFixed(2)
|
||||
@ -72,6 +100,56 @@ function formatSigned(n: number) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ws-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
background: #f0f2f5;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ws-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-status.is-on {
|
||||
color: var(--down);
|
||||
background: color-mix(in srgb, var(--down) 12%, #fff);
|
||||
}
|
||||
|
||||
.ws-status.is-pending {
|
||||
color: #d48806;
|
||||
background: color-mix(in srgb, #d48806 12%, #fff);
|
||||
}
|
||||
|
||||
.ws-status.is-pending .ws-dot {
|
||||
animation: ws-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ws-status.is-off {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes ws-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.price-block {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@ -6,7 +6,7 @@ export type { KlinePeriod }
|
||||
|
||||
/**
|
||||
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
|
||||
* 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。
|
||||
* 页面打开时拉分时/盘口;交易时段内自动连 WS(30s 检测),卸载时断开。
|
||||
* K 线点击 Tab 再拉。
|
||||
*/
|
||||
export function useQuote() {
|
||||
|
||||
@ -21,7 +21,9 @@ import {
|
||||
extractVariety,
|
||||
getPositionQueryDate,
|
||||
getPreviousTradingDate,
|
||||
isWsTradingSession,
|
||||
} from '../utils/tradingDate'
|
||||
import type { WsConnectionStatus } from '../api/baidu/ws'
|
||||
|
||||
/** K 线周期(日/周/月) */
|
||||
export type KlinePeriod = 'day' | 'week' | 'month'
|
||||
@ -111,6 +113,13 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
let quoteWs: BaiduQuoteWs | null = null
|
||||
/** When false, fetchQuote must not open WS (page unmounted). */
|
||||
let wsDesired = false
|
||||
/** 30s session gate: connect in trading hours, disconnect outside. */
|
||||
let sessionTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const wsStatus = ref<WsConnectionStatus>('disconnected')
|
||||
const wsInSession = ref(false)
|
||||
|
||||
const SESSION_CHECK_MS = 30_000
|
||||
|
||||
function handleWsMessage(msg: BaiduWsMessage) {
|
||||
if (msg.resultCode !== '0' || !msg.data) {
|
||||
@ -128,18 +137,54 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function stopSessionWatch() {
|
||||
if (sessionTimer) {
|
||||
clearInterval(sessionTimer)
|
||||
sessionTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Align WS with trading session:
|
||||
* - in session + not running → connect
|
||||
* - out of session + running → disconnect (keep wsDesired)
|
||||
*/
|
||||
function syncWsWithSession() {
|
||||
if (!wsDesired) return
|
||||
const inSession = isWsTradingSession()
|
||||
wsInSession.value = inSession
|
||||
if (inSession) {
|
||||
if (!quoteWs?.isRunning()) connectWs()
|
||||
} else if (quoteWs?.isRunning()) {
|
||||
quoteWs.disconnect()
|
||||
quoteWs = null
|
||||
wsStatus.value = 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
function startSessionWatch() {
|
||||
stopSessionWatch()
|
||||
syncWsWithSession()
|
||||
sessionTimer = setInterval(syncWsWithSession, SESSION_CHECK_MS)
|
||||
}
|
||||
|
||||
/** Allow WS after HTTP success (call on page mount before fetchQuote). */
|
||||
function enableWs() {
|
||||
wsDesired = true
|
||||
startSessionWatch()
|
||||
}
|
||||
|
||||
/** (Re)connect only while WS is still desired. */
|
||||
/** (Re)connect only while page wants WS and within trading session. */
|
||||
function connectWs() {
|
||||
if (!wsDesired) return
|
||||
if (!wsDesired || !isWsTradingSession()) return
|
||||
wsInSession.value = true
|
||||
quoteWs?.disconnect()
|
||||
quoteWs = new BaiduQuoteWs({
|
||||
onMessage: handleWsMessage,
|
||||
onError: (e) => console.error('[quota] ws error', e),
|
||||
onStatus: (s) => {
|
||||
wsStatus.value = s
|
||||
},
|
||||
})
|
||||
quoteWs.connect()
|
||||
}
|
||||
@ -147,8 +192,11 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
/** Stop WS and clear desire so in-flight fetchQuote cannot reopen it. */
|
||||
function disconnectWs() {
|
||||
wsDesired = false
|
||||
stopSessionWatch()
|
||||
quoteWs?.disconnect()
|
||||
quoteWs = null
|
||||
wsStatus.value = 'disconnected'
|
||||
wsInSession.value = false
|
||||
}
|
||||
|
||||
// ─── 新闻 ───────────────────────────────────────────────
|
||||
@ -338,6 +386,8 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
positions,
|
||||
positionsLoading,
|
||||
positionsError,
|
||||
wsStatus,
|
||||
wsInSession,
|
||||
// computed
|
||||
hasQuote,
|
||||
hasNews,
|
||||
|
||||
@ -51,6 +51,27 @@ export function extractVariety(contract: string): string {
|
||||
return m ? m[1] : contract
|
||||
}
|
||||
|
||||
/** 分钟数(从 0:00 起) */
|
||||
function minutesOfDay(d: Date): number {
|
||||
return d.getHours() * 60 + d.getMinutes()
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 应保持连接的交易时段(本地时区):
|
||||
* - 上午 09:00–11:30
|
||||
* - 下午 13:30–15:30
|
||||
* - 夜盘 21:00–23:00
|
||||
* 边界含起止分钟(如 11:30:xx 仍在时段内,11:31 起视为结束)。
|
||||
*/
|
||||
export function isWsTradingSession(now: Date = new Date()): boolean {
|
||||
const m = minutesOfDay(now)
|
||||
return (
|
||||
(m >= 9 * 60 && m <= 11 * 60 + 30) ||
|
||||
(m >= 13 * 60 + 30 && m <= 15 * 60 + 30) ||
|
||||
(m >= 21 * 60 && m <= 23 * 60)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近 N 个月日期区间(含起止日)。
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user