@@ -669,20 +669,12 @@ export interface SaveTickflowKeyResult {
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
realtime_quotes_enabled: boolean
|
||||
indices_nav_pinned: boolean
|
||||
minute_sync_enabled: boolean
|
||||
minute_sync_days: number
|
||||
daily_data_provider?: string
|
||||
adj_factor_provider?: string
|
||||
minute_data_provider?: string
|
||||
realtime_data_provider?: string
|
||||
realtime_watchlist_symbols?: string[]
|
||||
realtime_pull_stock?: boolean
|
||||
realtime_pull_etf?: boolean
|
||||
realtime_pull_index?: boolean
|
||||
realtime_index_mode?: 'core' | 'all'
|
||||
realtime_index_symbols?: string[]
|
||||
pipeline_pull_a_share: boolean
|
||||
pipeline_pull_etf: boolean
|
||||
pipeline_pull_index: boolean
|
||||
@@ -691,18 +683,9 @@ export interface Preferences {
|
||||
instruments_schedule: { hour: number; minute: number }
|
||||
enriched_batch_size: number
|
||||
index_daily_batch_size: number
|
||||
limit_ladder_monitor_enabled: boolean
|
||||
depth_polling_interval: number
|
||||
depth_finalize_time: { hour: number; minute: number }
|
||||
review_schedule: { enabled: boolean; hour: number; minute: number }
|
||||
review_push_channels: string[]
|
||||
sse_refresh_pages: Record<string, boolean>
|
||||
strategy_monitor_enabled: boolean
|
||||
strategy_monitor_ids: string[]
|
||||
system_notify_enabled: boolean
|
||||
feishu_webhook_url?: string
|
||||
feishu_webhook_secret?: string
|
||||
webhook_enabled_default?: boolean
|
||||
sidebar_index_symbols: string[]
|
||||
nav_order: string[]
|
||||
nav_hidden: string[]
|
||||
@@ -793,67 +776,11 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ symbols }),
|
||||
}),
|
||||
updateRealtimeQuotes: (enabled: boolean) =>
|
||||
request<{ realtime_quotes_enabled: boolean; realtime_allowed?: boolean; mode?: string; error?: string }>('/api/settings/preferences/realtime-quotes', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ realtime_quotes_enabled: enabled }),
|
||||
}),
|
||||
updateRealtimeQuoteScope: (cfg: Partial<Pick<Preferences, 'realtime_pull_stock' | 'realtime_pull_etf' | 'realtime_pull_index' | 'realtime_index_mode' | 'realtime_index_symbols'>>) =>
|
||||
request<Partial<Preferences>>('/api/settings/preferences/realtime-quote-scope', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(cfg),
|
||||
}),
|
||||
updateIndicesNavPinned: (pinned: boolean) =>
|
||||
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ indices_nav_pinned: pinned }),
|
||||
}),
|
||||
quoteStatus: () =>
|
||||
request<{
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
mode?: 'none' | 'watchlist' | 'full_market'
|
||||
realtime_allowed?: boolean
|
||||
interval_s: number
|
||||
symbol_count: number
|
||||
watchlist_symbol_count?: number
|
||||
index_symbol_count?: number
|
||||
etf_symbol_count?: number
|
||||
quote_age_ms: number | null
|
||||
is_trading_hours: boolean
|
||||
last_fetch_ms: number | null
|
||||
}>('/api/intraday/status'),
|
||||
quoteInterval: () =>
|
||||
request<{ interval: number; min_interval: number; max_interval: number }>(
|
||||
'/api/settings/preferences/quote-interval',
|
||||
),
|
||||
updateQuoteInterval: (interval: number) =>
|
||||
request<{ interval: number; min_interval: number; max_interval: number }>(
|
||||
'/api/settings/preferences/quote-interval',
|
||||
{ method: 'PUT', body: JSON.stringify({ interval }) },
|
||||
),
|
||||
intradayRefresh: () => request<{ status: string }>('/api/intraday/refresh', { method: 'POST' }),
|
||||
indexQuotes: (symbols?: string[]) =>
|
||||
request<{ rows: IndexQuote[]; count: number }>(
|
||||
`/api/intraday/indices${symbols?.length ? `?symbols=${encodeURIComponent(symbols.join(','))}` : ''}`,
|
||||
),
|
||||
updateRealtimeMonitorConfig: (cfg: {
|
||||
sse_refresh_pages?: Record<string, boolean>
|
||||
strategy_monitor_enabled?: boolean
|
||||
strategy_monitor_ids?: string[]
|
||||
sidebar_index_symbols?: string[]
|
||||
screener_auto_run?: boolean
|
||||
}) =>
|
||||
request<{
|
||||
sse_refresh_pages: Record<string, boolean>
|
||||
strategy_monitor_enabled: boolean
|
||||
strategy_monitor_ids: string[]
|
||||
sidebar_index_symbols: string[]
|
||||
screener_auto_run: boolean
|
||||
}>('/api/settings/preferences/realtime-monitor', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(cfg),
|
||||
}),
|
||||
updateSystemNotify: (enabled: boolean) =>
|
||||
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -9,7 +9,6 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
|
||||
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
|
||||
|
||||
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
|
||||
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
|
||||
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
|
||||
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
|
||||
}
|
||||
@@ -63,7 +62,7 @@ const TIER_STYLE: Record<string, TierStyle> = {
|
||||
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||
},
|
||||
expert: {
|
||||
desc: 'WebSocket · 财务数据',
|
||||
desc: '财务数据',
|
||||
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
|
||||
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
|
||||
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* 集中管理所有 React Query key。
|
||||
*
|
||||
* - 新增查询只需在此加一行,所有消费方自动引用。
|
||||
* - SSE invalidation 基于 SSE_INVALIDATE_PREFIXES 列表,新增 key 无需改 useQuoteStream。
|
||||
*/
|
||||
|
||||
// ===== Query Key 工厂 =====
|
||||
@@ -14,10 +13,7 @@ export const QK = {
|
||||
endpoints: ['endpoints'] as const,
|
||||
version: ['version'] as const,
|
||||
preferences: ['preferences'] as const,
|
||||
quoteStatus: ['quote-status'] as const,
|
||||
quoteInterval: ['quote-interval'] as const,
|
||||
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
|
||||
indexQuotes: ['index-quotes'] as const,
|
||||
indexList: ['index-list'] as const,
|
||||
|
||||
// Watchlist
|
||||
@@ -78,14 +74,4 @@ export const QK = {
|
||||
rpsRotation: (days: number) => ['rps-rotation', days] as const,
|
||||
} as const
|
||||
|
||||
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
|
||||
// 新增需要 SSE 推送的查询,只需在此加一行
|
||||
|
||||
export const SSE_INVALIDATE_PREFIXES = [
|
||||
'watchlist',
|
||||
'quote-status',
|
||||
'index-quotes',
|
||||
'overview-market',
|
||||
'limit-ladder',
|
||||
'screener',
|
||||
] as const
|
||||
// SSE invalidation has been removed along with real-time functionality.
|
||||
|
||||
@@ -35,10 +35,6 @@ const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null
|
||||
let state: ReviewState = { ...INITIAL }
|
||||
let abortCtrl: AbortController | null = null
|
||||
|
||||
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
|
||||
// 用于区分两条流, 避免互相丢弃事件或重复归档。
|
||||
let generatingSource: 'manual' | 'sse' | null = null
|
||||
|
||||
// ===== 订阅机制 =====
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
@@ -80,7 +76,6 @@ export async function startReviewGeneration(
|
||||
// 已在生成中,不重复启动
|
||||
if (isReviewGenerating()) return
|
||||
|
||||
generatingSource = 'manual'
|
||||
state = { phase: 'loading', content: '', error: '', meta: null, focus }
|
||||
notify()
|
||||
|
||||
@@ -114,7 +109,7 @@ export async function startReviewGeneration(
|
||||
if (buf && !failed) {
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
|
||||
// 自动归档
|
||||
if (buf && !failed) {
|
||||
onDone?.(buf, doneMeta)
|
||||
}
|
||||
@@ -126,7 +121,6 @@ export async function startReviewGeneration(
|
||||
}
|
||||
} finally {
|
||||
abortCtrl = null
|
||||
generatingSource = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,54 +161,4 @@ export function resetReview(): void {
|
||||
notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
|
||||
*
|
||||
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
|
||||
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
|
||||
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
|
||||
*
|
||||
* 事件格式与 recap_market_stream 产出一致:
|
||||
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
|
||||
*
|
||||
* 与手动生成的并发:
|
||||
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
|
||||
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
|
||||
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
|
||||
*/
|
||||
export function feedReviewEvent(evt: any): void {
|
||||
if (!evt || typeof evt !== 'object') return
|
||||
const t = evt.type
|
||||
|
||||
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
|
||||
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
|
||||
if (generatingSource === 'manual') return
|
||||
|
||||
if (t === 'meta') {
|
||||
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
|
||||
generatingSource = 'sse'
|
||||
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
|
||||
notify()
|
||||
} else if (t === 'delta' && evt.content) {
|
||||
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
|
||||
if (generatingSource !== 'sse') return
|
||||
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
|
||||
notify()
|
||||
} else if (t === 'retry') {
|
||||
if (generatingSource !== 'sse') return
|
||||
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
|
||||
state = { ...state, content: '', phase: 'streaming' }
|
||||
notify()
|
||||
} else if (t === 'error') {
|
||||
if (generatingSource !== 'sse') return
|
||||
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
|
||||
notify()
|
||||
generatingSource = null
|
||||
} else if (t === 'done') {
|
||||
if (generatingSource !== 'sse') return
|
||||
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
generatingSource = null
|
||||
}
|
||||
}
|
||||
// feedReviewEvent (SSE-only) removed along with real-time SSE channel.
|
||||
|
||||
@@ -31,10 +31,7 @@ function loadConfig(): QueryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量版:只读取当前配置。
|
||||
* 供 useQuoteStream 使用。
|
||||
*/
|
||||
/** 轻量版:只读取当前配置。 */
|
||||
export function getQueryConfig(): QueryConfig {
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
|
||||
import { getQueryConfig } from './useQueryConfig'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { pushAlertToasts } from '@/components/AlertToast'
|
||||
import { feedReviewEvent } from './reviewStore'
|
||||
import type { StrategyAlertEvent } from './api'
|
||||
|
||||
/**
|
||||
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
|
||||
*
|
||||
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
|
||||
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
|
||||
*
|
||||
* 应在顶层 Layout 中调用一次。
|
||||
*/
|
||||
export function useQuoteStream(
|
||||
enabled: boolean,
|
||||
sseRefreshPages: Record<string, boolean> | undefined,
|
||||
onAlert?: (alerts: StrategyAlertEvent[]) => void,
|
||||
) {
|
||||
const qc = useQueryClient()
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const pagesRef = useRef(sseRefreshPages)
|
||||
pagesRef.current = sseRefreshPages
|
||||
|
||||
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
|
||||
// depth 系统接管通知: 单独处理, 不走 strategy 回调
|
||||
const depthAlerts = alerts.filter(a => a.source === 'depth')
|
||||
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
|
||||
|
||||
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
|
||||
for (const a of depthAlerts.slice(0, 1)) {
|
||||
toast(a.message, 'success')
|
||||
}
|
||||
|
||||
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
|
||||
if (strategyAlerts.length > 0) {
|
||||
// 有 onAlert 回调时走回调, 否则弹 AlertToast
|
||||
if (onAlert) {
|
||||
onAlert(strategyAlerts)
|
||||
}
|
||||
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
|
||||
pushAlertToasts(strategyAlerts as any)
|
||||
}
|
||||
}, [onAlert])
|
||||
|
||||
const enabledRef = useRef(enabled)
|
||||
enabledRef.current = enabled
|
||||
|
||||
useEffect(() => {
|
||||
// SSE 始终连接 — 监控告警不依赖实时行情开关
|
||||
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
|
||||
|
||||
const connect = () => {
|
||||
const es = new EventSource('/api/intraday/stream')
|
||||
esRef.current = es
|
||||
|
||||
// sse-starlette ping 心跳走 SSE comment,不会到达这里
|
||||
|
||||
es.addEventListener('quotes_updated', () => {
|
||||
// 实时行情未开启时不处理行情刷新
|
||||
if (!enabledRef.current) return
|
||||
// 根据用户配置过滤 invalidation
|
||||
const pages = pagesRef.current
|
||||
if (pages) {
|
||||
// 只 invalidate 开启的页面对应的 prefix
|
||||
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
|
||||
// 'quote-status' 始终刷新 (全局状态)
|
||||
if (p === 'quote-status') return true
|
||||
return pages[p] !== false
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
activePrefixes.some(
|
||||
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
||||
),
|
||||
})
|
||||
} else {
|
||||
// 无配置时全部刷新 (向后兼容)
|
||||
qc.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
SSE_INVALIDATE_PREFIXES.some(
|
||||
(prefix) => String(query.queryKey[0]).startsWith(prefix),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('depth_updated', () => {
|
||||
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
|
||||
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
|
||||
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
|
||||
qc.invalidateQueries({ queryKey: ['overview-market'] })
|
||||
})
|
||||
|
||||
es.addEventListener('strategy_alert', (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
const alerts: StrategyAlertEvent[] = data.alerts || []
|
||||
if (alerts.length > 0) {
|
||||
handleAlerts(alerts)
|
||||
// 实时刷新触发记录列表 + 监控中心徽标
|
||||
qc.invalidateQueries({ queryKey: ['alerts'] })
|
||||
qc.invalidateQueries({ queryKey: ['alerts-total'] })
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
})
|
||||
|
||||
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
|
||||
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
|
||||
es.addEventListener('review_progress', (e: MessageEvent) => {
|
||||
try {
|
||||
const evt = JSON.parse(e.data)
|
||||
feedReviewEvent(evt)
|
||||
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
|
||||
if (evt.type === 'done') {
|
||||
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
esRef.current = null
|
||||
const delay = getQueryConfig().sse.reconnectDelay
|
||||
retryRef.current = setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
clearTimeout(retryRef.current)
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
}
|
||||
}, [qc, handleAlerts])
|
||||
}
|
||||
@@ -5,30 +5,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from './api'
|
||||
import { QK } from './queryKeys'
|
||||
|
||||
/** 切换实时行情 — Layout / Data 共用 */
|
||||
export function useToggleRealtimeQuotes() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新行情轮询间隔 — Layout / Data 共用 */
|
||||
export function useUpdateQuoteInterval() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (v: number) => api.updateQuoteInterval(v),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(QK.quoteInterval, data)
|
||||
qc.invalidateQueries({ queryKey: QK.quoteStatus })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量添加自选 — Screener / Intraday 共用 */
|
||||
export function useWatchlistBatchAdd() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
|
||||
*
|
||||
* 实时数据走 SSE invalidation,无需前端轮询。
|
||||
* 只有管线进度等非 SSE 数据才用 refetchInterval。
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from './api'
|
||||
@@ -34,33 +31,6 @@ export function usePreferences() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 行情状态 — SSE quotes_updated 自动刷新。
|
||||
|
||||
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
|
||||
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
|
||||
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
|
||||
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
|
||||
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
|
||||
*/
|
||||
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: QK.quoteStatus,
|
||||
queryFn: api.quoteStatus,
|
||||
enabled: opts?.enabled ?? true,
|
||||
refetchInterval: opts?.poll
|
||||
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
|
||||
: false,
|
||||
})
|
||||
}
|
||||
|
||||
/** 行情间隔 — Layout / Data 共用 */
|
||||
export function useQuoteInterval() {
|
||||
return useQuery({
|
||||
queryKey: QK.quoteInterval,
|
||||
queryFn: api.quoteInterval,
|
||||
})
|
||||
}
|
||||
|
||||
/** 版本号 — Layout 专用 */
|
||||
export function useVersion() {
|
||||
return useQuery({
|
||||
|
||||
Reference in New Issue
Block a user