feat: 题材列表盘中120s缓存共用,题材详情新增全量新闻分页+强度热度行情
- 题材热点与热点穿透共用题材列表缓存:盘中由"不缓存实时拉取"改为120s短缓存, 东财全量列表拉取降到每120s一次,图重建时涨幅/热度两榜直接命中缓存 - 详情页相关新闻升级为全量分页(getThemeRelatedNews):支持翻页+评论数, 替换原 getDetail 固定3条 - 详情页统计条新增强度+热度(getSingleThemeQuote)实时指标 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,39 @@ async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
|
||||||
|
async def theme_news(
|
||||||
|
theme_code: str,
|
||||||
|
page_num: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
|
||||||
|
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
|
||||||
|
):
|
||||||
|
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"data": None, "theme_code": theme_code},
|
||||||
|
headers=_NO_CACHE_HEADERS,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{"data": result, "theme_code": theme_code},
|
||||||
|
headers=_NO_CACHE_HEADERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
|
||||||
|
async def theme_quote(theme_code: str):
|
||||||
|
result = await themes.fetch_theme_quote(theme_code)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"data": None, "theme_code": theme_code},
|
||||||
|
headers=_NO_CACHE_HEADERS,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{"data": result, "theme_code": theme_code},
|
||||||
|
headers=_NO_CACHE_HEADERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{theme_code}/detail", summary="题材详情")
|
@router.get("/{theme_code}/detail", summary="题材详情")
|
||||||
async def theme_detail(theme_code: str):
|
async def theme_detail(theme_code: str):
|
||||||
data = await themes.fetch_theme_detail(theme_code)
|
data = await themes.fetch_theme_detail(theme_code)
|
||||||
|
|||||||
@@ -88,9 +88,14 @@ def _next_open_delta_seconds() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
|
||||||
|
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
|
||||||
|
_LIST_CACHE_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
def _list_ttl_seconds() -> int:
|
def _list_ttl_seconds() -> int:
|
||||||
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
|
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
|
||||||
return 0 if _is_trading_time() else _next_open_delta_seconds()
|
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
|
||||||
|
|
||||||
|
|
||||||
# ---- 请求封装 ----
|
# ---- 请求封装 ----
|
||||||
@@ -159,11 +164,10 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]
|
|||||||
asc: True=升序, False=降序
|
asc: True=升序, False=降序
|
||||||
"""
|
"""
|
||||||
cache_key = f"theme_list:{sort_field}:{asc}"
|
cache_key = f"theme_list:{sort_field}:{asc}"
|
||||||
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
|
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
|
||||||
if not _is_trading_time():
|
cached = get_cache(cache_key)
|
||||||
cached = get_cache(cache_key)
|
if cached is not None:
|
||||||
if cached is not None:
|
return json.loads(cached)
|
||||||
return json.loads(cached)
|
|
||||||
|
|
||||||
sort = 1 if asc else -1
|
sort = 1 if asc else -1
|
||||||
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
|
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
|
||||||
@@ -443,3 +447,56 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1
|
|||||||
data = await _build_theme_graph(sort_field, top_n)
|
data = await _build_theme_graph(sort_field, top_n)
|
||||||
_set_graph_cache(cache_key, data)
|
_set_graph_cache(cache_key, data)
|
||||||
return _trim_graph_result(data, limit)
|
return _trim_graph_result(data, limit)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 题材相关新闻(分页) ----
|
||||||
|
|
||||||
|
_NEWS_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]:
|
||||||
|
"""获取题材相关新闻(分页),返回 {total, maxEuTime, list}
|
||||||
|
|
||||||
|
maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。
|
||||||
|
盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。
|
||||||
|
"""
|
||||||
|
cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}"
|
||||||
|
cached = get_cache(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await _post(
|
||||||
|
"/api/themeInvest/getThemeRelatedNews",
|
||||||
|
{"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size},
|
||||||
|
app_key=_APP_KEY_DETAIL,
|
||||||
|
)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ttl_s = _graph_ttl_seconds()
|
||||||
|
if ttl_s > 0:
|
||||||
|
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 单题材实时行情(强度/热度/涨幅) ----
|
||||||
|
|
||||||
|
async def fetch_theme_quote(theme_code: str) -> Optional[dict]:
|
||||||
|
"""获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存"""
|
||||||
|
cache_key = f"theme_quote:{theme_code}"
|
||||||
|
cached = get_cache(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
data = await _post(
|
||||||
|
"/api/themeInvest/getSingleThemeQuote",
|
||||||
|
{"themeCode": theme_code},
|
||||||
|
app_key=_APP_KEY_DETAIL,
|
||||||
|
)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ttl_s = _graph_ttl_seconds()
|
||||||
|
if ttl_s > 0:
|
||||||
|
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||||
|
return data
|
||||||
|
|||||||
@@ -159,6 +159,75 @@ export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 题材相关新闻(分页) ── */
|
||||||
|
|
||||||
|
export interface ThemeNewsItem {
|
||||||
|
newsCode: string;
|
||||||
|
newsTitle: string;
|
||||||
|
newsMediaName: string;
|
||||||
|
showDateTime: number | null;
|
||||||
|
showDateTimeFormat: string | null;
|
||||||
|
commentCount: number;
|
||||||
|
themeCode: string;
|
||||||
|
themeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeNewsResponse {
|
||||||
|
total: number;
|
||||||
|
maxEuTime: string;
|
||||||
|
list: ThemeNewsItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取题材相关新闻(分页,maxEuTime 为翻页游标)
|
||||||
|
*/
|
||||||
|
export async function fetchThemeNews(
|
||||||
|
themeCode: string,
|
||||||
|
pageNum: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
maxEuTime: string = "",
|
||||||
|
): Promise<ThemeNewsResponse | null> {
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const url = `${baseUrl}/api/themes/${themeCode}/news?page_num=${pageNum}&page_size=${pageSize}&max_eu_time=${encodeURIComponent(maxEuTime)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const result = await resp.json();
|
||||||
|
return result.data || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[theme-api] 获取题材新闻失败:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 单题材实时行情(强度/热度/涨幅) ── */
|
||||||
|
|
||||||
|
export interface ThemeQuote {
|
||||||
|
strengthValue: number | null;
|
||||||
|
hotValueUpLimit: number;
|
||||||
|
hotValue: number;
|
||||||
|
f3: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单题材实时行情
|
||||||
|
*/
|
||||||
|
export async function fetchThemeQuote(themeCode: string): Promise<ThemeQuote | null> {
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const url = `${baseUrl}/api/themes/${themeCode}/quote`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const result = await resp.json();
|
||||||
|
return result.data || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[theme-api] 获取题材行情失败:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 热点穿透:题材-股票 网状关系图 ── */
|
/* ── 热点穿透:题材-股票 网状关系图 ── */
|
||||||
|
|
||||||
export interface GraphTheme {
|
export interface GraphTheme {
|
||||||
|
|||||||
+99
-24
@@ -1,7 +1,14 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api";
|
import {
|
||||||
|
fetchThemeDetail,
|
||||||
|
fetchThemeNews,
|
||||||
|
fetchThemeQuote,
|
||||||
|
fetchThemeStocks,
|
||||||
|
type ThemeStock,
|
||||||
|
type ThemeNewsItem,
|
||||||
|
} from "@/lib/theme-api";
|
||||||
import { getStockBoard } from "@/lib/stock-api";
|
import { getStockBoard } from "@/lib/stock-api";
|
||||||
import { formatMoney } from "@/lib/utils";
|
import { formatMoney } from "@/lib/utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
|
||||||
|
const [newsPage, setNewsPage] = useState(1);
|
||||||
|
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
|
||||||
|
const newsQ = useQuery({
|
||||||
|
queryKey: ["themeNews", code, newsPage],
|
||||||
|
queryFn: () => fetchThemeNews(code, newsPage, 10),
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const quoteQ = useQuery({
|
||||||
|
queryKey: ["themeQuote", code],
|
||||||
|
queryFn: () => fetchThemeQuote(code),
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分页追加:首页重置列表,翻页拼接
|
||||||
|
useEffect(() => {
|
||||||
|
if (!newsQ.data?.list) return;
|
||||||
|
setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list]));
|
||||||
|
}, [newsQ.data, newsPage]);
|
||||||
|
|
||||||
const isLoading = detailQ.isLoading || stocksQ.isLoading;
|
const isLoading = detailQ.isLoading || stocksQ.isLoading;
|
||||||
const isError = detailQ.isError || stocksQ.isError;
|
const isError = detailQ.isError || stocksQ.isError;
|
||||||
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
detailQ.refetch();
|
detailQ.refetch();
|
||||||
stocksQ.refetch();
|
stocksQ.refetch();
|
||||||
|
quoteQ.refetch();
|
||||||
|
setNewsPage(1);
|
||||||
|
newsQ.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseInfo = detail?.baseInfo;
|
const baseInfo = detail?.baseInfo;
|
||||||
const hotEvent = detail?.hotEvent;
|
const hotEvent = detail?.hotEvent;
|
||||||
const eventHistory = detail?.eventHistory ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
|
|||||||
flat={statistic?.f106}
|
flat={statistic?.f106}
|
||||||
fex5={statistic?.fex5}
|
fex5={statistic?.fex5}
|
||||||
total={total}
|
total={total}
|
||||||
|
strength={quoteQ.data?.strengthValue ?? null}
|
||||||
|
hotValue={quoteQ.data?.hotValue ?? 0}
|
||||||
|
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── 相关新闻(可折叠) ── */}
|
{/* ── 相关新闻(分页加载) ── */}
|
||||||
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
|
{newsItems.length > 0 && (
|
||||||
|
<NewsList
|
||||||
|
items={newsItems}
|
||||||
|
total={newsQ.data?.total ?? 0}
|
||||||
|
loadingMore={newsQ.isFetching && newsPage > 1}
|
||||||
|
onLoadMore={() => setNewsPage((p) => p + 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 相关股票 ── */}
|
{/* ── 相关股票 ── */}
|
||||||
<div>
|
<div>
|
||||||
@@ -181,6 +221,9 @@ function StatBar({
|
|||||||
flat,
|
flat,
|
||||||
fex5,
|
fex5,
|
||||||
total,
|
total,
|
||||||
|
strength,
|
||||||
|
hotValue,
|
||||||
|
hotValueUpLimit,
|
||||||
}: {
|
}: {
|
||||||
f3: number | null | undefined;
|
f3: number | null | undefined;
|
||||||
up: number | null | undefined;
|
up: number | null | undefined;
|
||||||
@@ -188,8 +231,13 @@ function StatBar({
|
|||||||
flat: number | null | undefined;
|
flat: number | null | undefined;
|
||||||
fex5: number | null | undefined;
|
fex5: number | null | undefined;
|
||||||
total: number;
|
total: number;
|
||||||
|
strength: number | null;
|
||||||
|
hotValue: number;
|
||||||
|
hotValueUpLimit: number;
|
||||||
}) {
|
}) {
|
||||||
const isPos = (f3 ?? 0) >= 0;
|
const isPos = (f3 ?? 0) >= 0;
|
||||||
|
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
|
||||||
|
const showQuote = strength != null || hotValueUpLimit > 0;
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
@@ -218,24 +266,49 @@ function StatBar({
|
|||||||
平盘 {flat} 只
|
平盘 {flat} 只
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{/* 强度 + 热度(来自单题材实时行情接口) */}
|
||||||
|
{showQuote && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||||||
|
{strength != null && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
强度
|
||||||
|
<b className="font-bold text-foreground tabular-nums">{strength}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hotValueUpLimit > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Flame className="h-3 w-3 text-orange-500" />
|
||||||
|
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
||||||
|
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
热度 {hotValue}/{hotValueUpLimit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
相关新闻(可折叠)
|
相关新闻(分页加载)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
|
function NewsList({
|
||||||
const [expanded, setExpanded] = useState(false);
|
items,
|
||||||
const shown = expanded ? items : items.slice(0, 2);
|
total,
|
||||||
|
loadingMore,
|
||||||
const fmtTime = (ts: number | null) => {
|
onLoadMore,
|
||||||
if (!ts) return "";
|
}: {
|
||||||
const d = new Date(ts);
|
items: ThemeNewsItem[];
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
total: number;
|
||||||
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
loadingMore: boolean;
|
||||||
};
|
onLoadMore: () => void;
|
||||||
|
}) {
|
||||||
|
const hasMore = items.length < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
|
|||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<Newspaper className="h-4 w-4 text-primary" />
|
<Newspaper className="h-4 w-4 text-primary" />
|
||||||
<h2 className="text-sm font-semibold">相关新闻</h2>
|
<h2 className="text-sm font-semibold">相关新闻</h2>
|
||||||
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} 条</span>
|
<span className="text-[10px] text-muted-foreground ml-auto">共 {total} 条</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{shown.map((n, idx) => (
|
{items.map((n, idx) => (
|
||||||
<div key={idx} className="space-y-0.5">
|
<div key={idx} className="space-y-0.5">
|
||||||
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{n.newsMediaName}
|
{n.newsMediaName}
|
||||||
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
|
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
|
||||||
|
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{items.length > 2 && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={onLoadMore}
|
||||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
|
disabled={loadingMore}
|
||||||
|
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{expanded ? "收起" : `展开全部 ${items.length} 条`}
|
{loadingMore ? "加载中…" : "加载更多"}
|
||||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
{!loadingMore && <ChevronDown className="h-3 w-3" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user