ws连接自动连接与断开

This commit is contained in:
dongzp 2026-07-22 10:12:21 +08:00
parent 496e5e9e6e
commit 8fe2373b2d
6 changed files with 194 additions and 5 deletions

View File

@ -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()

View File

@ -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')
}
}
}

View File

@ -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;

View File

@ -6,7 +6,7 @@ export type { KlinePeriod }
/**
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
* 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。
* 页面打开时拉分时/盘口;交易时段内自动连 WS(30s 检测),卸载时断开。
* K 线点击 Tab 再拉。
*/
export function useQuote() {

View File

@ -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,

View File

@ -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 个月日期区间(含起止日)。
*/