解决内存泄露问题
This commit is contained in:
parent
3f68c81bf1
commit
4dae5e5d57
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user