更新Company页面的UI为FUI风格
This commit is contained in:
48
app.py
48
app.py
@@ -43,6 +43,7 @@ else:
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import threading
|
||||
import time
|
||||
import urllib
|
||||
import uuid
|
||||
@@ -7517,20 +7518,16 @@ def get_stock_quotes():
|
||||
prev_close_map[norm_code] = base_close_map[base_code]
|
||||
|
||||
# 批量查询当前价格数据(从 ClickHouse)
|
||||
# 使用 argMax 函数获取最新价格,比窗口函数效率高很多
|
||||
batch_price_query = """
|
||||
WITH last_prices AS (
|
||||
SELECT
|
||||
code,
|
||||
close as last_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
)
|
||||
SELECT code, last_price
|
||||
FROM last_prices
|
||||
WHERE rn = 1
|
||||
SELECT
|
||||
code,
|
||||
argMax(close, timestamp) as last_price
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
GROUP BY code
|
||||
"""
|
||||
|
||||
batch_data = client.execute(batch_price_query, {
|
||||
@@ -7626,14 +7623,25 @@ def get_stock_quotes():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== ClickHouse 连接池(单例模式) ====================
|
||||
_clickhouse_client = None
|
||||
_clickhouse_client_lock = threading.Lock()
|
||||
|
||||
def get_clickhouse_client():
|
||||
return Cclient(
|
||||
host='127.0.0.1',
|
||||
port=9000,
|
||||
user='default',
|
||||
password='Zzl33818!',
|
||||
database='stock'
|
||||
)
|
||||
"""获取 ClickHouse 客户端(单例模式,避免重复创建连接)"""
|
||||
global _clickhouse_client
|
||||
if _clickhouse_client is None:
|
||||
with _clickhouse_client_lock:
|
||||
if _clickhouse_client is None:
|
||||
_clickhouse_client = Cclient(
|
||||
host='127.0.0.1',
|
||||
port=9000,
|
||||
user='default',
|
||||
password='Zzl33818!',
|
||||
database='stock'
|
||||
)
|
||||
print("[ClickHouse] 创建新连接(单例)")
|
||||
return _clickhouse_client
|
||||
|
||||
|
||||
@app.route('/api/account/calendar/events', methods=['GET', 'POST'])
|
||||
|
||||
@@ -198,10 +198,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
}
|
||||
}, [sectionState.stocks, stocks.length, refreshQuotes]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
|
||||
}, []);
|
||||
|
||||
// 历史事件对比 - 数据已预加载,只需切换展开状态
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
@@ -350,14 +346,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
|
||||
{/* 相关概念(手风琴样式) - 需要 PRO 权限 */}
|
||||
<RelatedConceptsSection
|
||||
eventId={event.id}
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
isOpen={sectionState.concepts.isOpen}
|
||||
onToggle={handleConceptsToggle}
|
||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessConcepts}
|
||||
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
|
||||
|
||||
@@ -1,145 +1,140 @@
|
||||
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件(主组件)
|
||||
// 相关概念区组件 - 折叠手风琴样式
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Button,
|
||||
Collapse,
|
||||
Heading,
|
||||
Center,
|
||||
Spinner,
|
||||
Text,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Icon,
|
||||
Collapse,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { ChevronRightIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
import { logger } from '@utils/logger';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
/**
|
||||
* 单个概念项组件(手风琴项)
|
||||
*/
|
||||
const ConceptItem = ({ concept, isExpanded, onToggle, onNavigate }) => {
|
||||
const itemBg = useColorModeValue('white', 'gray.700');
|
||||
const itemHoverBg = useColorModeValue('gray.50', 'gray.650');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const conceptColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const reasonBg = useColorModeValue('blue.50', 'gray.800');
|
||||
const reasonColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const iconColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
bg={itemBg}
|
||||
>
|
||||
{/* 概念标题行 - 可点击展开 */}
|
||||
<Flex
|
||||
px={3}
|
||||
py={2.5}
|
||||
cursor="pointer"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
_hover={{ bg: itemHoverBg }}
|
||||
onClick={onToggle}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Icon
|
||||
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
|
||||
color={iconColor}
|
||||
boxSize={4}
|
||||
transition="transform 0.2s"
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={conceptColor}
|
||||
cursor="pointer"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(concept);
|
||||
}}
|
||||
>
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<Badge colorScheme="green" fontSize="xs" flexShrink={0}>
|
||||
AI 分析
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 关联原因 - 可折叠 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box
|
||||
px={4}
|
||||
py={3}
|
||||
bg={reasonBg}
|
||||
borderTop="1px solid"
|
||||
borderTopColor={borderColor}
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={reasonColor}
|
||||
lineHeight="1.8"
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{concept.reason || '暂无关联原因说明'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 相关概念区组件
|
||||
* @param {Object} props
|
||||
* @param {number} props.eventId - 事件ID(用于获取 related_concepts 表数据)
|
||||
* @param {string} props.eventTitle - 事件标题(备用,当 eventId 不存在时使用搜索)
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
* @param {string} props.eventTitle - 事件标题(备用)
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
|
||||
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费)
|
||||
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
|
||||
* @param {boolean} props.isLocked - 是否锁定(需要付费)
|
||||
* @param {Function} props.onLockedClick - 锁定时的点击回调
|
||||
*/
|
||||
const RelatedConceptsSection = ({
|
||||
eventId,
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
|
||||
onToggle = undefined // 新增:受控模式(外部控制展开回调)
|
||||
}) => {
|
||||
// 使用外部 isOpen,如果没有则使用内部 useState
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
// 记录每个概念的展开状态
|
||||
const [expandedItems, setExpandedItems] = useState({});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 颜色配置
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const countBadgeBg = useColorModeValue('blue.100', 'blue.800');
|
||||
const countBadgeColor = useColorModeValue('blue.700', 'blue.200');
|
||||
|
||||
console.log('[RelatedConceptsSection] 组件渲染', {
|
||||
eventId,
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
loading,
|
||||
conceptsCount: concepts?.length || 0,
|
||||
error
|
||||
});
|
||||
|
||||
// 获取相关概念 - 优先使用 eventId 从数据库获取
|
||||
// 获取相关概念
|
||||
useEffect(() => {
|
||||
const fetchConcepts = async () => {
|
||||
console.log('[RelatedConceptsSection] useEffect 触发', {
|
||||
eventId,
|
||||
eventTitle,
|
||||
effectiveTradingDate
|
||||
});
|
||||
|
||||
// 优先使用 eventId 获取数据库中的相关概念
|
||||
if (eventId) {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`;
|
||||
console.log('[RelatedConceptsSection] 从数据库获取相关概念', { url: apiUrl });
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 如果是 403,说明需要订阅,不是错误
|
||||
if (response.status === 403) {
|
||||
console.log('[RelatedConceptsSection] 需要订阅才能查看');
|
||||
setConcepts([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[RelatedConceptsSection] 数据库响应', data);
|
||||
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
// 转换数据格式,使其与原有展示逻辑兼容
|
||||
const formattedConcepts = data.data.map(item => ({
|
||||
concept: item.concept,
|
||||
reason: item.reason,
|
||||
// 保留原有字段以兼容 DetailedConceptCard
|
||||
score: 1, // 数据库中的都是高相关度
|
||||
description: item.reason, // reason 作为描述
|
||||
stocks: [], // 暂无股票数据
|
||||
stock_count: 0
|
||||
}));
|
||||
console.log('[RelatedConceptsSection] 设置概念数据', formattedConcepts);
|
||||
setConcepts(formattedConcepts);
|
||||
} else {
|
||||
setConcepts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RelatedConceptsSection] 获取概念失败', err);
|
||||
logger.error('RelatedConceptsSection', 'fetchConcepts', err);
|
||||
setError('加载概念数据失败');
|
||||
setConcepts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 降级方案:使用 eventTitle 搜索概念(兼容旧逻辑)
|
||||
if (!eventTitle || !effectiveTradingDate) {
|
||||
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
|
||||
hasEventId: !!eventId,
|
||||
hasEventTitle: !!eventTitle,
|
||||
hasEffectiveTradingDate: !!effectiveTradingDate
|
||||
});
|
||||
if (!eventId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -148,48 +143,35 @@ const RelatedConceptsSection = ({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 格式化交易日期
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
query: eventTitle,
|
||||
size: 5,
|
||||
page: 1,
|
||||
sort_by: "_score",
|
||||
trade_date: formattedTradeDate
|
||||
};
|
||||
|
||||
const apiUrl = `${getApiBase()}/concept-api/search`;
|
||||
console.log('[RelatedConceptsSection] 降级:使用搜索接口', { url: apiUrl, requestBody });
|
||||
|
||||
const apiUrl = `${getApiBase()}/api/events/${eventId}/concepts`;
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
setConcepts([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
setConcepts(data.results);
|
||||
} else if (data.data && data.data.concepts) {
|
||||
setConcepts(data.data.concepts);
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setConcepts(data.data);
|
||||
// 默认展开第一个
|
||||
if (data.data.length > 0) {
|
||||
setExpandedItems({ 0: true });
|
||||
}
|
||||
} else {
|
||||
setConcepts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RelatedConceptsSection] 搜索概念失败', err);
|
||||
console.error('[RelatedConceptsSection] 获取概念失败', err);
|
||||
logger.error('RelatedConceptsSection', 'fetchConcepts', err);
|
||||
setError('加载概念数据失败');
|
||||
setConcepts([]);
|
||||
} finally {
|
||||
@@ -198,93 +180,66 @@ const RelatedConceptsSection = ({
|
||||
};
|
||||
|
||||
fetchConcepts();
|
||||
}, [eventId, eventTitle, effectiveTradingDate]);
|
||||
}, [eventId]);
|
||||
|
||||
// 切换某个概念的展开状态
|
||||
const toggleItem = (index) => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
return;
|
||||
}
|
||||
setExpandedItems(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
};
|
||||
|
||||
// 跳转到概念中心
|
||||
const handleNavigate = (concept) => {
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" mr={2} />
|
||||
<Spinner size="sm" color="blue.500" mr={2} />
|
||||
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 判断是否有数据
|
||||
const hasNoConcepts = !concepts || concepts.length === 0;
|
||||
|
||||
/**
|
||||
* 根据相关度获取颜色(浅色背景 + 深色文字)
|
||||
* @param {number} relevance - 相关度(0-100)
|
||||
* @returns {Object} 包含背景色和文字色
|
||||
*/
|
||||
const getRelevanceColor = (relevance) => {
|
||||
if (relevance >= 90) {
|
||||
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
|
||||
} else if (relevance >= 80) {
|
||||
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
|
||||
} else if (relevance >= 70) {
|
||||
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
|
||||
} else {
|
||||
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理概念点击
|
||||
* @param {Object} concept - 概念对象
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念中心,并搜索该概念
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 标题栏 - 两行布局 */}
|
||||
<Box mb={3}>
|
||||
{/* 第一行:标题 + Badge + 按钮 */}
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
相关概念
|
||||
</Heading>
|
||||
{/* 订阅徽章 */}
|
||||
{subscriptionBadge}
|
||||
</Flex>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
onClick={() => {
|
||||
// 如果被锁定且有回调函数,触发付费弹窗
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (onToggle !== undefined) {
|
||||
// 受控模式:调用外部回调
|
||||
onToggle();
|
||||
} else {
|
||||
// 非受控模式:使用内部状态
|
||||
setInternalExpanded(!internalExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '收起' : '查看详细'}
|
||||
</Button>
|
||||
</Flex>
|
||||
{/* 第二行:交易日期信息 */}
|
||||
<TradingDateInfo
|
||||
effectiveTradingDate={effectiveTradingDate}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
{/* 标题栏 */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<HStack spacing={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
相关概念
|
||||
</Heading>
|
||||
{!hasNoConcepts && (
|
||||
<Badge
|
||||
bg={countBadgeBg}
|
||||
color={countBadgeColor}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
>
|
||||
{concepts.length}
|
||||
</Badge>
|
||||
)}
|
||||
{subscriptionBadge}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||||
{/* 概念列表 - 手风琴样式 */}
|
||||
{hasNoConcepts ? (
|
||||
<Box mb={isExpanded ? 3 : 0}>
|
||||
<Box py={2}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm">{error}</Text>
|
||||
) : (
|
||||
@@ -292,41 +247,18 @@ const RelatedConceptsSection = ({
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
<ConceptItem
|
||||
key={concept.id || index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
isExpanded={!!expandedItems[index]}
|
||||
onToggle={() => toggleItem(index)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{hasNoConcepts ? (
|
||||
<Box py={4}>
|
||||
{error ? (
|
||||
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
|
||||
) : (
|
||||
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
/* 详细概念卡片网格 */
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user