ws连接自动连接与断开
This commit is contained in:
parent
496e5e9e6e
commit
8fe2373b2d
10
src/App.vue
10
src/App.vue
@ -1,7 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<template #header>
|
<template #header>
|
||||||
<AppHeader v-if="quote" :quote="quote" />
|
<AppHeader
|
||||||
|
v-if="quote"
|
||||||
|
:quote="quote"
|
||||||
|
:ws-status="wsStatus"
|
||||||
|
:ws-in-session="wsInSession"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section class="quote-section">
|
<section class="quote-section">
|
||||||
@ -64,6 +69,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import AppLayout from './layout/AppLayout.vue'
|
import AppLayout from './layout/AppLayout.vue'
|
||||||
import AppHeader from './components/header/AppHeader.vue'
|
import AppHeader from './components/header/AppHeader.vue'
|
||||||
import QuoteStats from './components/quote/QuoteStats.vue'
|
import QuoteStats from './components/quote/QuoteStats.vue'
|
||||||
@ -79,8 +85,10 @@ import { useQuote } from './composables/useQuote'
|
|||||||
import { useNews } from './composables/useNews'
|
import { useNews } from './composables/useNews'
|
||||||
import { usePositions } from './composables/usePositions'
|
import { usePositions } from './composables/usePositions'
|
||||||
import { useAiAdvice } from './composables/useAiAdvice'
|
import { useAiAdvice } from './composables/useAiAdvice'
|
||||||
|
import { useQuotaStore } from './stores/quota'
|
||||||
|
|
||||||
const { data: quote, klineLoading, loadKline } = useQuote()
|
const { data: quote, klineLoading, loadKline } = useQuote()
|
||||||
|
const { wsStatus, wsInSession } = storeToRefs(useQuotaStore())
|
||||||
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
|
const { data: news, loading: newsLoading, error: newsError, refresh: refreshNews } = useNews()
|
||||||
const { data: positions } = usePositions()
|
const { data: positions } = usePositions()
|
||||||
const { data: advice, loading: adviceLoading, refresh: refreshAdvice } = useAiAdvice()
|
const { data: advice, loading: adviceLoading, refresh: refreshAdvice } = useAiAdvice()
|
||||||
|
|||||||
@ -16,9 +16,13 @@ function resolveWsUrl(): string {
|
|||||||
return `${proto}//${host}/finance-ws/`
|
return `${proto}//${host}/finance-ws/`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** UI / store 用的连接状态 */
|
||||||
|
export type WsConnectionStatus = 'disconnected' | 'connecting' | 'connected'
|
||||||
|
|
||||||
export type BaiduQuoteWsHandlers = {
|
export type BaiduQuoteWsHandlers = {
|
||||||
onMessage: (msg: BaiduWsMessage) => void
|
onMessage: (msg: BaiduWsMessage) => void
|
||||||
onError?: (err: unknown) => void
|
onError?: (err: unknown) => void
|
||||||
|
onStatus?: (status: WsConnectionStatus) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildItem(): BaiduWsItem {
|
function buildItem(): BaiduWsItem {
|
||||||
@ -41,13 +45,26 @@ export class BaiduQuoteWs {
|
|||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private reconnectAttempt = 0
|
private reconnectAttempt = 0
|
||||||
private intentionalClose = false
|
private intentionalClose = false
|
||||||
|
/** True from connect() until intentional disconnect(); survives transient closes. */
|
||||||
|
private running = false
|
||||||
|
private status: WsConnectionStatus = 'disconnected'
|
||||||
private handlers: BaiduQuoteWsHandlers
|
private handlers: BaiduQuoteWsHandlers
|
||||||
|
|
||||||
constructor(handlers: BaiduQuoteWsHandlers) {
|
constructor(handlers: BaiduQuoteWsHandlers) {
|
||||||
this.handlers = handlers
|
this.handlers = handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this client should stay connected (including reconnect backoff). */
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.running
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): WsConnectionStatus {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
|
this.running = true
|
||||||
this.intentionalClose = false
|
this.intentionalClose = false
|
||||||
this.clearReconnect()
|
this.clearReconnect()
|
||||||
if (
|
if (
|
||||||
@ -60,6 +77,7 @@ export class BaiduQuoteWs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
this.running = false
|
||||||
this.intentionalClose = true
|
this.intentionalClose = true
|
||||||
this.clearTimers()
|
this.clearTimers()
|
||||||
this.clearReconnect()
|
this.clearReconnect()
|
||||||
@ -75,11 +93,19 @@ export class BaiduQuoteWs {
|
|||||||
}
|
}
|
||||||
this.ws = null
|
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 {
|
private openSocket(): void {
|
||||||
if (this.intentionalClose) return
|
if (this.intentionalClose) return
|
||||||
|
|
||||||
|
this.setStatus('connecting')
|
||||||
const ws = new WebSocket(resolveWsUrl())
|
const ws = new WebSocket(resolveWsUrl())
|
||||||
this.ws = ws
|
this.ws = ws
|
||||||
|
|
||||||
@ -93,6 +119,7 @@ export class BaiduQuoteWs {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.reconnectAttempt = 0
|
this.reconnectAttempt = 0
|
||||||
|
this.setStatus('connected')
|
||||||
this.sendSubscribe()
|
this.sendSubscribe()
|
||||||
this.startTimers()
|
this.startTimers()
|
||||||
}
|
}
|
||||||
@ -115,7 +142,12 @@ export class BaiduQuoteWs {
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
this.clearTimers()
|
this.clearTimers()
|
||||||
this.ws = null
|
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="code">{{ quote.code }}</span>
|
||||||
<span class="exchange">{{ quote.exchange }}</span>
|
<span class="exchange">{{ quote.exchange }}</span>
|
||||||
<el-tag size="small" type="info" effect="plain">{{ quote.status }}</el-tag>
|
<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>
|
||||||
<div class="price-block">
|
<div class="price-block">
|
||||||
<span class="last" :class="priceClass">{{ quote.last.toFixed(2) }}</span>
|
<span class="last" :class="priceClass">{{ quote.last.toFixed(2) }}</span>
|
||||||
@ -25,10 +29,13 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Setting } from '@element-plus/icons-vue'
|
import { Setting } from '@element-plus/icons-vue'
|
||||||
import type { QuoteData } from '../../types'
|
import type { QuoteData } from '../../types'
|
||||||
|
import type { WsConnectionStatus } from '../../api/baidu/ws'
|
||||||
import SettingsDialog from './SettingsDialog.vue'
|
import SettingsDialog from './SettingsDialog.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
quote: QuoteData
|
quote: QuoteData
|
||||||
|
wsStatus: WsConnectionStatus
|
||||||
|
wsInSession: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const settingsVisible = ref(false)
|
const settingsVisible = ref(false)
|
||||||
@ -37,6 +44,27 @@ const priceClass = computed(() =>
|
|||||||
props.quote.change >= 0 ? 'price-up' : 'price-down',
|
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) {
|
function formatSigned(n: number) {
|
||||||
if (n > 0) return `+${Number(n).toFixed(2)}`
|
if (n > 0) return `+${Number(n).toFixed(2)}`
|
||||||
return Number(n).toFixed(2)
|
return Number(n).toFixed(2)
|
||||||
@ -72,6 +100,56 @@ function formatSigned(n: number) {
|
|||||||
font-size: 13px;
|
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 {
|
.price-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export type { KlinePeriod }
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
|
* 行情 composable:状态统一落在 quota store,便于 AI 分析读取。
|
||||||
* 页面打开时拉分时/盘口并建立 WebSocket;卸载时断开。
|
* 页面打开时拉分时/盘口;交易时段内自动连 WS(30s 检测),卸载时断开。
|
||||||
* K 线点击 Tab 再拉。
|
* K 线点击 Tab 再拉。
|
||||||
*/
|
*/
|
||||||
export function useQuote() {
|
export function useQuote() {
|
||||||
|
|||||||
@ -21,7 +21,9 @@ import {
|
|||||||
extractVariety,
|
extractVariety,
|
||||||
getPositionQueryDate,
|
getPositionQueryDate,
|
||||||
getPreviousTradingDate,
|
getPreviousTradingDate,
|
||||||
|
isWsTradingSession,
|
||||||
} from '../utils/tradingDate'
|
} from '../utils/tradingDate'
|
||||||
|
import type { WsConnectionStatus } from '../api/baidu/ws'
|
||||||
|
|
||||||
/** K 线周期(日/周/月) */
|
/** K 线周期(日/周/月) */
|
||||||
export type KlinePeriod = 'day' | 'week' | 'month'
|
export type KlinePeriod = 'day' | 'week' | 'month'
|
||||||
@ -111,6 +113,13 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
let quoteWs: BaiduQuoteWs | null = null
|
let quoteWs: BaiduQuoteWs | null = null
|
||||||
/** When false, fetchQuote must not open WS (page unmounted). */
|
/** When false, fetchQuote must not open WS (page unmounted). */
|
||||||
let wsDesired = false
|
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) {
|
function handleWsMessage(msg: BaiduWsMessage) {
|
||||||
if (msg.resultCode !== '0' || !msg.data) {
|
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). */
|
/** Allow WS after HTTP success (call on page mount before fetchQuote). */
|
||||||
function enableWs() {
|
function enableWs() {
|
||||||
wsDesired = true
|
wsDesired = true
|
||||||
|
startSessionWatch()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** (Re)connect only while WS is still desired. */
|
/** (Re)connect only while page wants WS and within trading session. */
|
||||||
function connectWs() {
|
function connectWs() {
|
||||||
if (!wsDesired) return
|
if (!wsDesired || !isWsTradingSession()) return
|
||||||
|
wsInSession.value = true
|
||||||
quoteWs?.disconnect()
|
quoteWs?.disconnect()
|
||||||
quoteWs = new BaiduQuoteWs({
|
quoteWs = new BaiduQuoteWs({
|
||||||
onMessage: handleWsMessage,
|
onMessage: handleWsMessage,
|
||||||
onError: (e) => console.error('[quota] ws error', e),
|
onError: (e) => console.error('[quota] ws error', e),
|
||||||
|
onStatus: (s) => {
|
||||||
|
wsStatus.value = s
|
||||||
|
},
|
||||||
})
|
})
|
||||||
quoteWs.connect()
|
quoteWs.connect()
|
||||||
}
|
}
|
||||||
@ -147,8 +192,11 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
/** Stop WS and clear desire so in-flight fetchQuote cannot reopen it. */
|
/** Stop WS and clear desire so in-flight fetchQuote cannot reopen it. */
|
||||||
function disconnectWs() {
|
function disconnectWs() {
|
||||||
wsDesired = false
|
wsDesired = false
|
||||||
|
stopSessionWatch()
|
||||||
quoteWs?.disconnect()
|
quoteWs?.disconnect()
|
||||||
quoteWs = null
|
quoteWs = null
|
||||||
|
wsStatus.value = 'disconnected'
|
||||||
|
wsInSession.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 新闻 ───────────────────────────────────────────────
|
// ─── 新闻 ───────────────────────────────────────────────
|
||||||
@ -338,6 +386,8 @@ export const useQuotaStore = defineStore('quota', () => {
|
|||||||
positions,
|
positions,
|
||||||
positionsLoading,
|
positionsLoading,
|
||||||
positionsError,
|
positionsError,
|
||||||
|
wsStatus,
|
||||||
|
wsInSession,
|
||||||
// computed
|
// computed
|
||||||
hasQuote,
|
hasQuote,
|
||||||
hasNews,
|
hasNews,
|
||||||
|
|||||||
@ -51,6 +51,27 @@ export function extractVariety(contract: string): string {
|
|||||||
return m ? m[1] : contract
|
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 个月日期区间(含起止日)。
|
* 最近 N 个月日期区间(含起止日)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user