解决内存泄露问题

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

View File

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

View File

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

View File

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

View File

@ -137,10 +137,11 @@ export const useQuotaStore = defineStore('quota', () => {
const code = activeContract().code const code = activeContract().code
if (msg.data.code && msg.data.code !== code) return if (msg.data.code && msg.data.code !== code) return
const product = msg.data.product const product = msg.data.product
// Mutate in place — replacing quote.value forces every chart/UI to rebuild.
if (product === 'tick') { if (product === 'tick') {
quote.value = applyWsTick(quote.value, msg.data as BaiduWsTickData) applyWsTick(quote.value, msg.data as BaiduWsTickData)
} else if (product === 'snapshot') { } 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() 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() { function connectWs() {
if (!wsDesired || !isWsTradingSession()) return if (!wsDesired || !isWsTradingSession()) return
wsInSession.value = true wsInSession.value = true
if (quoteWs?.isRunning()) return
quoteWs?.disconnect() quoteWs?.disconnect()
quoteWs = new BaiduQuoteWs({ quoteWs = new BaiduQuoteWs({
onMessage: handleWsMessage, onMessage: handleWsMessage,