解决内存泄露问题

This commit is contained in:
tony 2026-07-23 23:48:04 +08:00
parent 3f68c81bf1
commit 4dae5e5d57
5 changed files with 148 additions and 58 deletions

View File

@ -58,7 +58,7 @@ function upsertIntraday(
const price = toNum(priceRaw)
const avg = toNum(avgRaw)
const totalVolume = toNum(totalVolumeRaw)
const list = [...points]
const list = points.slice()
const idx = list.findIndex((p) => p.time === time)
const othersSum = list.reduce((s, p, i) => (i === idx ? s : s + p.volume), 0)
@ -73,8 +73,11 @@ function upsertIntraday(
return list
}
/** Merge tick detailinfos into quote.trades (newest first, dedupe, cap 200). */
export function applyWsTick(quote: QuoteData, data: BaiduWsTickData): QuoteData {
/**
* Merge tick detailinfos into quote.trades (newest first, dedupe, cap 200).
* Mutates quote in place so Vue only invalidates trade-dependent effects.
*/
export function applyWsTick(quote: QuoteData, data: BaiduWsTickData): void {
const incoming: TradeTick[] = [...(data.detailinfos ?? [])]
.reverse()
.map((t) => ({
@ -93,26 +96,27 @@ export function applyWsTick(quote: QuoteData, data: BaiduWsTickData): QuoteData
merged.push(t)
if (merged.length >= MAX_TRADES) break
}
return { ...quote, trades: merged }
quote.trades = merged
}
/** Apply snapshot fields onto quote (price, book, pankou, intraday point). */
export function applyWsSnapshot(quote: QuoteData, data: BaiduWsSnapshotData): QuoteData {
const next: QuoteData = { ...quote }
/**
* Apply snapshot fields onto quote (price, book, pankou, intraday point).
* Mutates quote in place — do not replace quote.value, or every tick rebuilds charts.
*/
export function applyWsSnapshot(quote: QuoteData, data: BaiduWsSnapshotData): void {
if (data.cur) {
const c = data.cur
if (c.price != null) next.last = toNum(c.price, next.last)
if (c.increase != null) next.change = toNum(c.increase, next.change)
if (c.ratio != null) next.changePercent = toNum(c.ratio, next.changePercent)
if (c.avgPrice != null) next.avg = toNum(c.avgPrice, next.avg)
if (c.status) next.status = c.status
if (c.price != null) quote.last = toNum(c.price, quote.last)
if (c.increase != null) quote.change = toNum(c.increase, quote.change)
if (c.ratio != null) quote.changePercent = toNum(c.ratio, quote.changePercent)
if (c.avgPrice != null) quote.avg = toNum(c.avgPrice, quote.avg)
if (c.status) quote.status = c.status
}
if (data.update) {
if (data.update.text) next.updatedAt = data.update.text
if (data.update.stockStatus) next.status = data.update.stockStatus
else if (data.update.tradeStatusCN) next.status = data.update.tradeStatusCN
if (data.update.text) quote.updatedAt = data.update.text
if (data.update.stockStatus) quote.status = data.update.stockStatus
else if (data.update.tradeStatusCN) quote.status = data.update.tradeStatusCN
}
if (data.pankouinfos?.length) {
@ -120,20 +124,20 @@ export function applyWsSnapshot(quote: QuoteData, data: BaiduWsSnapshotData): Qu
const num = (ename: string, fallback: number) =>
by[ename] ? toNum(by[ename]!.originValue ?? by[ename]!.value, fallback) : fallback
next.open = num('open', next.open)
next.high = num('high', next.high)
next.low = num('low', next.low)
next.prevClose = num('preClose', next.prevClose)
next.volume = num('volume', next.volume)
next.openInterest = num('holdingAmount', next.openInterest)
next.amplitude = num('amplitudeRatio', next.amplitude)
next.settlement = num('settlement', next.settlement)
next.prevSettlement = num('prevSettlement', next.prevSettlement)
next.outerVol = num('outside', next.outerVol)
next.innerVol = num('inside', next.innerVol)
if (by.avgPrice) next.avg = num('avgPrice', next.avg)
quote.open = num('open', quote.open)
quote.high = num('high', quote.high)
quote.low = num('low', quote.low)
quote.prevClose = num('preClose', quote.prevClose)
quote.volume = num('volume', quote.volume)
quote.openInterest = num('holdingAmount', quote.openInterest)
quote.amplitude = num('amplitudeRatio', quote.amplitude)
quote.settlement = num('settlement', quote.settlement)
quote.prevSettlement = num('prevSettlement', quote.prevSettlement)
quote.outerVol = num('outside', quote.outerVol)
quote.innerVol = num('inside', quote.innerVol)
if (by.avgPrice) quote.avg = num('avgPrice', quote.avg)
if (by.amount) {
next.amount = parseAmountYi(by.amount.value, by.amount.originValue)
quote.amount = parseAmountYi(by.amount.value, by.amount.originValue)
}
if (by.amountDelta) {
const raw = by.amountDelta.originValue
@ -141,7 +145,7 @@ export function applyWsSnapshot(quote: QuoteData, data: BaiduWsSnapshotData): Qu
const unavailable =
(raw == null || !Number.isFinite(raw)) && (!display || display === '--')
if (!unavailable) {
next.amountDelta = parseAmountDelta(display, raw)
quote.amountDelta = parseAmountDelta(display, raw)
}
}
}
@ -166,20 +170,18 @@ export function applyWsSnapshot(quote: QuoteData, data: BaiduWsSnapshotData): Qu
const bidVol = bids.reduce((s, b) => s + b.volume, 0)
const askVol = asks.reduce((s, a) => s + a.volume, 0)
const total = bidVol + askVol
next.orderBook = { asks, bids }
next.buyRatio = total > 0 ? Math.round((bidVol / total) * 100) : 50
next.sellRatio = 100 - next.buyRatio
quote.orderBook = { asks, bids }
quote.buyRatio = total > 0 ? Math.round((bidVol / total) * 100) : 50
quote.sellRatio = 100 - quote.buyRatio
}
if (data.point) {
next.intraday = upsertIntraday(
next.intraday,
quote.intraday = upsertIntraday(
quote.intraday,
data.point.price,
data.point.avgPrice,
data.point.time,
data.point.totalVolume,
)
}
return next
}

View File

@ -6,12 +6,12 @@
<el-tab-pane :label="TAB.week" name="week" />
<el-tab-pane :label="TAB.month" name="month" />
</el-tabs>
<v-chart class="chart" :option="option" autoresize />
<v-chart class="chart" :option="option" :update-options="updateOpts" autoresize />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, CandlestickChart } from 'echarts/charts'
@ -23,7 +23,7 @@ import {
} from 'echarts/components'
import type { EChartsOption } from 'echarts'
import VChart from 'vue-echarts'
import type { Candle, QuoteData } from '../../types'
import type { Candle, IntradayPoint, QuoteData } from '../../types'
import type { KlinePeriod } from '../../stores/quota'
use([
@ -37,7 +37,7 @@ use([
LegendComponent,
])
/** ASCII-safe unicode escapes ? avoids Windows encoding corruption */
/** ASCII-safe unicode escapes — avoids Windows encoding corruption */
const TAB = {
intraday: '\u5206\u65f6',
day: '\u65e5K',
@ -53,6 +53,9 @@ interface ColorParam {
dataIndex?: number
}
/** Throttle chart option rebuilds under high-frequency WS snapshots. */
const CHART_THROTTLE_MS = 400
const props = defineProps<{
quote: QuoteData
klineLoading?: boolean
@ -60,27 +63,71 @@ const props = defineProps<{
}>()
const period = ref<ChartPeriod>('intraday')
const option = ref<EChartsOption>({})
const updateOpts = { notMerge: true, lazyUpdate: true }
let throttleTimer: ReturnType<typeof setTimeout> | null = null
let pendingBuild = false
async function onTabChange(name: string | number) {
if (name === 'day' || name === 'week' || name === 'month') {
await props.loadKline(name)
}
flushOption(true)
}
const option = computed((): EChartsOption => {
function buildOption(): EChartsOption {
if (period.value === 'intraday') {
return buildIntradayOption(props.quote)
return buildIntradayOption(props.quote.intraday, props.quote.prevSettlement)
}
return buildCandleOption(props.quote.candles[period.value])
}
function flushOption(immediate = false) {
if (immediate) {
if (throttleTimer) {
clearTimeout(throttleTimer)
throttleTimer = null
}
pendingBuild = false
option.value = buildOption()
return
}
if (throttleTimer) {
pendingBuild = true
return
}
option.value = buildOption()
throttleTimer = setTimeout(() => {
throttleTimer = null
if (pendingBuild) {
pendingBuild = false
option.value = buildOption()
}
}, CHART_THROTTLE_MS)
}
// Only depend on chart-relevant fields (not trades / orderBook).
watch(
() => {
if (period.value === 'intraday') {
return [period.value, props.quote.intraday, props.quote.prevSettlement] as const
}
return [period.value, props.quote.candles[period.value]] as const
},
() => flushOption(false),
{ immediate: true },
)
onUnmounted(() => {
if (throttleTimer) clearTimeout(throttleTimer)
})
function buildIntradayOption(quote: QuoteData): EChartsOption {
const points = quote.intraday
function buildIntradayOption(points: IntradayPoint[], base: number): EChartsOption {
const times = points.map((p) => p.time)
const prices = points.map((p) => p.price)
const avgs = points.map((p) => p.avg)
const vols = points.map((p) => p.volume)
const base = quote.prevSettlement
return {
animation: false,
@ -121,7 +168,8 @@ function buildIntradayOption(quote: QuoteData): EChartsOption {
position: 'right',
axisLabel: {
fontSize: 10,
formatter: (v: number) => `${(((v - base) / base) * 100).toFixed(2)}%`,
formatter: (v: number) =>
base ? `${(((v - base) / base) * 100).toFixed(2)}%` : '0.00%',
},
splitLine: { show: false },
gridIndex: 0,

View File

@ -11,7 +11,7 @@
</div>
</div>
</div>
<v-chart class="chart" :option="option" autoresize />
<v-chart class="chart" :option="option" :update-options="updateOpts" autoresize />
<div class="legend right">
<div v-for="item in sellItems" :key="item.key" class="legend-item">
<span class="swatch" :style="{ background: item.color }" />
@ -26,7 +26,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
@ -39,6 +39,8 @@ import type { QuoteData, TradeTick } from '../../types'
use([CanvasRenderer, PieChart, TooltipComponent])
const PIE_THROTTLE_MS = 500
const props = defineProps<{
quote: QuoteData
}>()
@ -123,18 +125,23 @@ function toItems(
return { buyItems, sellItems, data: [...buyItems, ...sellItems] }
}
const stats = computed(() => toItems(aggregate(props.quote.trades, threshold.value)))
const stats = ref(toItems(aggregate([], threshold.value)))
const buyItems = computed(() => stats.value.buyItems)
const sellItems = computed(() => stats.value.sellItems)
const option = ref<EChartsOption>({})
const updateOpts = { notMerge: true, lazyUpdate: true }
const option = computed((): EChartsOption => {
const data = stats.value.data.map((d) => ({
let throttleTimer: ReturnType<typeof setTimeout> | null = null
let pending = false
function buildPieOption(items: ReturnType<typeof toItems>): EChartsOption {
const data = items.data.map((d) => ({
name: d.name,
value: d.value,
itemStyle: { color: d.color },
}))
return {
animation: false,
tooltip: {
trigger: 'item',
formatter: '{b}<br/>手数: {c}<br/>占比: {d}%',
@ -151,6 +158,35 @@ const option = computed((): EChartsOption => {
},
],
}
}
function flushStats() {
const next = toItems(aggregate(props.quote.trades, threshold.value))
stats.value = next
option.value = buildPieOption(next)
}
watch(
() => [props.quote.trades, threshold.value] as const,
() => {
if (throttleTimer) {
pending = true
return
}
flushStats()
throttleTimer = setTimeout(() => {
throttleTimer = null
if (pending) {
pending = false
flushStats()
}
}, PIE_THROTTLE_MS)
},
{ immediate: true },
)
onUnmounted(() => {
if (throttleTimer) clearTimeout(throttleTimer)
})
</script>

View File

@ -108,8 +108,10 @@ export function useAiAdvice() {
snapshot: quota.getAnalysisSnapshot(),
}
console.log('[AI] 提交给 DeepSeek 的关键词', payload.keywords)
console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot)
// 勿 console.log 完整 snapshot:DevTools 会长期持有大对象导致内存暴涨
if (import.meta.env.DEV) {
console.log('[AI] 分析', payload.snapshot.contract.code, payload.keywords.slice(0, 80))
}
const advice = await fetchDeepSeekAdvice(apiKey, payload)
if (epoch !== analysisEpoch) return

View File

@ -137,10 +137,11 @@ export const useQuotaStore = defineStore('quota', () => {
const code = activeContract().code
if (msg.data.code && msg.data.code !== code) return
const product = msg.data.product
// Mutate in place — replacing quote.value forces every chart/UI to rebuild.
if (product === 'tick') {
quote.value = applyWsTick(quote.value, msg.data as BaiduWsTickData)
applyWsTick(quote.value, msg.data as BaiduWsTickData)
} else if (product === 'snapshot') {
quote.value = applyWsSnapshot(quote.value, msg.data as BaiduWsSnapshotData)
applyWsSnapshot(quote.value, msg.data as BaiduWsSnapshotData)
}
}
@ -181,10 +182,11 @@ export const useQuotaStore = defineStore('quota', () => {
startSessionWatch()
}
/** (Re)connect only while page wants WS and within trading session. */
/** Connect only while page wants WS and within trading session; skip if already running. */
function connectWs() {
if (!wsDesired || !isWsTradingSession()) return
wsInSession.value = true
if (quoteWs?.isRunning()) return
quoteWs?.disconnect()
quoteWs = new BaiduQuoteWs({
onMessage: handleWsMessage,