共创详情/族谱: 世代数改前端按子树深度计算;面板核心改动说明读版本摘要层
Project CI / Backend tests (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 4m25s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m41s
Project CI / Repository checks (pull_request) Failing after 17s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m59s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m29s
Project CI / Backend tests (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 4m25s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m41s
Project CI / Repository checks (pull_request) Failing after 17s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m59s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m29s
This commit is contained in:
@@ -58,7 +58,10 @@ import {
|
||||
VERSION_STATUS_LABEL,
|
||||
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
|
||||
} from './gameVersionHistoryModel';
|
||||
import { collectLineageAncestorChain } from './lineageTreeLayout';
|
||||
import {
|
||||
collectLineageAncestorChain,
|
||||
collectLineageSubtreeDepth,
|
||||
} from './lineageTreeLayout';
|
||||
import { useGameAuthorDisplayName } from './useGameAuthorName';
|
||||
import { useGameReviews } from './useGameReviews';
|
||||
|
||||
@@ -577,15 +580,11 @@ function GameDetailContent({
|
||||
GameDistributionLineageNode[]
|
||||
>([]);
|
||||
/**
|
||||
* 「累计创作世代数」:另一个 session 正在给公开详情补这个字段(暂定名
|
||||
* `descendantGenerationCount`)。**按「字段可能存在」写**:缺失时整项不渲染,
|
||||
* 不拿 `forkCount` 或别的数字凑数(避免误导)。字段到位后这里自然生效。
|
||||
* 「累计创作世代数」=**本作品子树的深度**(子树最大代际 − 本作品代际)。
|
||||
* 用详情页已经拿到的 `/lineage` 同一份数据在前端算,不发新请求;
|
||||
* 空树 / 只有自己时是 0(界面上给出「暂无衍生」文案)。
|
||||
*/
|
||||
const descendantGenerationCount =
|
||||
'descendantGenerationCount' in game &&
|
||||
typeof game.descendantGenerationCount === 'number'
|
||||
? game.descendantGenerationCount
|
||||
: undefined;
|
||||
const [descendantDepth, setDescendantDepth] = useState<number | null>(null);
|
||||
const loadCoCreationData = useCallback(async () => {
|
||||
if (!publiclyVisible) {
|
||||
setLineageChain([]);
|
||||
@@ -612,6 +611,9 @@ function GameDetailContent({
|
||||
setLineageChain(
|
||||
tree ? collectLineageAncestorChain(tree.nodes, game.id) : [],
|
||||
);
|
||||
setDescendantDepth(
|
||||
tree ? collectLineageSubtreeDepth(tree.nodes, game.id) : null,
|
||||
);
|
||||
setDerivedNodes(derived?.nodes ?? []);
|
||||
}, [game.id, publiclyVisible]);
|
||||
useEffect(() => {
|
||||
@@ -871,14 +873,15 @@ function GameDetailContent({
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{/* 数据概览:累计衍生作品数 + 累计创作世代数(后者等后端字段,缺失不渲染)。 */}
|
||||
{/* 数据概览:累计衍生作品数 + 累计创作世代数(子树深度,前端用同一份 lineage 数据算)。 */}
|
||||
<span className="co-creation-stats">
|
||||
<span className="co-creation-stat">
|
||||
累计衍生作品 {forkCount}
|
||||
</span>
|
||||
{descendantGenerationCount === undefined ? null : (
|
||||
{descendantDepth === null ? null : (
|
||||
<span className="co-creation-stat">
|
||||
累计创作世代数 {descendantGenerationCount}
|
||||
累计创作世代数 {descendantDepth}
|
||||
{descendantDepth === 0 ? '(暂无衍生)' : ''}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -980,12 +983,9 @@ function GameDetailContent({
|
||||
handleForkLaunch();
|
||||
}}
|
||||
>
|
||||
{isAuthenticated
|
||||
? '一键 Fork 共创'
|
||||
: '登录后一键 Fork'}
|
||||
{isAuthenticated ? '一键 Fork 共创' : '登录后一键 Fork'}
|
||||
</button>
|
||||
{(isAuthenticated ||
|
||||
forkAuthorization === 'forbidden') &&
|
||||
{(isAuthenticated || forkAuthorization === 'forbidden') &&
|
||||
forkLaunchNotice ? (
|
||||
<span className="game-fork-remix-status" role="status">
|
||||
{forkLaunchNotice}
|
||||
|
||||
@@ -1854,14 +1854,18 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const blocked = await screen.findByRole('button', { name: '一键 Fork 共创' });
|
||||
const blocked = await screen.findByRole('button', {
|
||||
name: '一键 Fork 共创',
|
||||
});
|
||||
// 不静默隐藏:按钮在,但是 aria-disabled + 有原因(悬停 title)。
|
||||
expect(blocked.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(blocked.getAttribute('title')).toContain('禁止共创');
|
||||
// 点击给出可读原因(不发起唤起)。
|
||||
fireEvent.click(blocked);
|
||||
expect(
|
||||
await screen.findByText(/作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u),
|
||||
await screen.findByText(
|
||||
/作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(openForkProtocolMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1883,7 +1887,9 @@ describe('GameDetailPage Fork 入口', () => {
|
||||
|
||||
await screen.findByText('作品状态');
|
||||
expect(screen.queryByRole('button', { name: '一键 Fork 共创' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '登录后一键 Fork' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '登录后一键 Fork' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('未登录时入口显示「登录后一键 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
|
||||
@@ -2351,9 +2357,9 @@ describe('GameDetailPage 共创主题入口', () => {
|
||||
const openButton = (title: string) =>
|
||||
screen.getByRole('button', { name: `打开《${title}》详情` });
|
||||
const cardOf = async (title: string) =>
|
||||
(
|
||||
await screen.findByRole('button', { name: `打开《${title}》详情` })
|
||||
).closest('.game-lineage-node') as HTMLElement;
|
||||
(await screen.findByRole('button', { name: `打开《${title}》详情` })).closest(
|
||||
'.game-lineage-node',
|
||||
) as HTMLElement;
|
||||
|
||||
describe('GameLineagePage 可视化与交互', () => {
|
||||
const treeWithSibling = {
|
||||
@@ -2795,9 +2801,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
|
||||
await screen.findByText('深代作品');
|
||||
const changeType = screen.getByLabelText('按改动类型筛选');
|
||||
expect((changeType as HTMLSelectElement).disabled).toBe(true);
|
||||
expect(
|
||||
screen.getByText('核心改动说明字段尚未上线'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('改动说明是自由文本,暂无可筛选分类')).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('按世代筛选'), {
|
||||
target: { value: '1' },
|
||||
@@ -2858,6 +2862,11 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
|
||||
createGame({
|
||||
summary: '这是一个二创作品的简介',
|
||||
forkAuthorization: 'nonCommercial',
|
||||
currentVersion: {
|
||||
id: 'v1',
|
||||
versionNumber: 1,
|
||||
changeSummary: '把跳台改成三段,并重画全部背景',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const onOpenDetail = vi.fn();
|
||||
@@ -2875,10 +2884,15 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
|
||||
fireEvent.click(hotCard);
|
||||
|
||||
const panel = await screen.findByLabelText('选中作品信息');
|
||||
expect(await within(panel).findByText('这是一个二创作品的简介')).toBeTruthy();
|
||||
expect(
|
||||
await within(panel).findByText('这是一个二创作品的简介'),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByText(/允许非商用共创/u)).toBeTruthy();
|
||||
// 「核心改动说明」字段还没上:不渲染这一行(不假装有数据)。
|
||||
expect(within(panel).queryByText(/核心改动说明/u)).toBeNull();
|
||||
// 核心改动说明来自版本摘要层(selectedGame.currentVersion.changeSummary)。
|
||||
expect(
|
||||
within(panel).getByText(/核心改动说明:把跳台改成三段/u),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '前往体验' }));
|
||||
expect(onOpenDetail).toHaveBeenCalledWith('hot');
|
||||
@@ -2913,9 +2927,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
|
||||
expect(screen.getByText('2 代三创 · 改编自《原初之光》')).toBeTruthy();
|
||||
// 主操作按文档叫「一键 Fork 共创」。
|
||||
expect(
|
||||
screen.getByRole('button', { name: '一键 Fork 共创' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => {
|
||||
@@ -2979,7 +2991,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
screen.getByRole('button', { name: /0 代作品《根原创》/u }),
|
||||
);
|
||||
expect(onOpenGameDetail).toHaveBeenCalledWith('game-root');
|
||||
fireEvent.click(screen.getByRole('button', { name: /2 代作品《当前作品》/u }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /2 代作品《当前作品》/u }),
|
||||
);
|
||||
expect(onOpenGameDetail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2988,9 +3002,27 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
getDerivedGamesMock.mockResolvedValueOnce({
|
||||
gameId: 'game-1',
|
||||
nodes: [
|
||||
createLineageNode({ gameId: 'child-low', title: '低热度', playCount: 1, generation: 1, parentGameId: 'game-1' }),
|
||||
createLineageNode({ gameId: 'child-hot', title: '高热度', playCount: 99, generation: 1, parentGameId: 'game-1' }),
|
||||
createLineageNode({ gameId: 'child-mid', title: '中热度', playCount: 50, generation: 1, parentGameId: 'game-1' }),
|
||||
createLineageNode({
|
||||
gameId: 'child-low',
|
||||
title: '低热度',
|
||||
playCount: 1,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'child-hot',
|
||||
title: '高热度',
|
||||
playCount: 99,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'child-mid',
|
||||
title: '中热度',
|
||||
playCount: 50,
|
||||
generation: 1,
|
||||
parentGameId: 'game-1',
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
@@ -3012,7 +3044,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
expect(hot.textContent?.indexOf('高热度')).toBeLessThan(
|
||||
hot.textContent?.indexOf('中热度') ?? -1,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /热门衍生《高热度》/u }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /热门衍生《高热度》/u }),
|
||||
);
|
||||
expect(onOpenGameDetail).toHaveBeenCalledWith('child-hot');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,7 +195,10 @@ function LineageTreeNodeCard({
|
||||
{generationAxisLabel(generation)}
|
||||
</span>
|
||||
{layoutNode.isTrunk ? (
|
||||
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
|
||||
<span
|
||||
className="game-lineage-node__trunk"
|
||||
title="该代热度最高的版本"
|
||||
>
|
||||
主干
|
||||
</span>
|
||||
) : null}
|
||||
@@ -343,7 +346,8 @@ export function GameLineagePage({
|
||||
.map((item) => item.node);
|
||||
}, [generationFilter, nodes, sortByHeat]);
|
||||
const generationsPresent = useMemo(
|
||||
() => [...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
|
||||
() =>
|
||||
[...new Set(nodes.map((node) => node.generation))].sort((a, b) => a - b),
|
||||
[nodes],
|
||||
);
|
||||
// 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
|
||||
@@ -497,12 +501,9 @@ export function GameLineagePage({
|
||||
const selectedForkAuthorization = selectedGame
|
||||
? resolveGameForkAuthorization(selectedGame.forkAuthorization)
|
||||
: null;
|
||||
/** 核心改动说明在**版本摘要层**(`currentVersion.changeSummary`),从这一次公开详情里读。 */
|
||||
const selectedCoreChangeNote =
|
||||
selectedGame && 'coreChangeNote' in selectedGame
|
||||
? typeof selectedGame.coreChangeNote === 'string'
|
||||
? selectedGame.coreChangeNote
|
||||
: ''
|
||||
: '';
|
||||
selectedGame?.currentVersion?.changeSummary?.trim() ?? '';
|
||||
|
||||
const zoomBy = useCallback((factor: number) => {
|
||||
setViewport((current) => {
|
||||
@@ -672,10 +673,16 @@ export function GameLineagePage({
|
||||
<span>世代</span>
|
||||
<select
|
||||
aria-label="按世代筛选"
|
||||
value={generationFilter === null ? 'all' : String(generationFilter)}
|
||||
value={
|
||||
generationFilter === null
|
||||
? 'all'
|
||||
: String(generationFilter)
|
||||
}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setGenerationFilter(value === 'all' ? null : Number(value));
|
||||
setGenerationFilter(
|
||||
value === 'all' ? null : Number(value),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
@@ -695,8 +702,8 @@ export function GameLineagePage({
|
||||
按热度排序
|
||||
</label>
|
||||
{/*
|
||||
改动类型筛选依赖「本次核心改动说明」字段(后端还没上)。
|
||||
按「字段可能存在」处理:**禁用 + 说明**,不假装能筛。
|
||||
改动类型筛选:`changeSummary` 字段已上线,但它是**自由文本**(服务端没有分类枚举),
|
||||
因此仍然**禁用 + 说明**,不编造筛选值域;等后端给出可枚举的改动类型再接。
|
||||
*/}
|
||||
<label className="game-lineage-filter game-lineage-filter--disabled">
|
||||
<span>改动类型</span>
|
||||
@@ -704,7 +711,7 @@ export function GameLineagePage({
|
||||
<option>暂不可用</option>
|
||||
</select>
|
||||
<span className="game-lineage-filter__note">
|
||||
核心改动说明字段尚未上线
|
||||
改动说明是自由文本,暂无可筛选分类
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildLineageTreeLayout,
|
||||
collectLineageAncestorChain,
|
||||
collectLineageHighlight,
|
||||
collectLineageSubtreeDepth,
|
||||
generationAxisLabel,
|
||||
LINEAGE_COLUMN_GAP,
|
||||
LINEAGE_COVER_ASPECT,
|
||||
@@ -274,7 +275,10 @@ describe('主干线(每代热度最高的版本)', () => {
|
||||
expect(byId.get('hot')!.isTrunk).toBe(true);
|
||||
expect(byId.get('cold')!.isTrunk).toBe(false);
|
||||
const trunkEdges = layout.edges.filter((edge) => edge.isTrunk);
|
||||
expect(trunkEdges.map((edge) => edge.id)).toEqual(['root->hot', 'hot->hot-child']);
|
||||
expect(trunkEdges.map((edge) => edge.id)).toEqual([
|
||||
'root->hot',
|
||||
'hot->hot-child',
|
||||
]);
|
||||
});
|
||||
|
||||
it('热度更高的节点成为主干(显式 playCount 覆盖)', () => {
|
||||
@@ -293,6 +297,34 @@ describe('主干线(每代热度最高的版本)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLineageSubtreeDepth', () => {
|
||||
it('深度 = 子树最大代际 − 本作品代际', () => {
|
||||
const nodes = [
|
||||
node('root', 0, null),
|
||||
node('a', 1, 'root'),
|
||||
node('b', 2, 'a'),
|
||||
node('c', 3, 'b'),
|
||||
node('sibling', 1, 'root'),
|
||||
];
|
||||
expect(collectLineageSubtreeDepth(nodes, 'root')).toBe(3);
|
||||
expect(collectLineageSubtreeDepth(nodes, 'a')).toBe(2);
|
||||
expect(collectLineageSubtreeDepth(nodes, 'sibling')).toBe(0);
|
||||
});
|
||||
|
||||
it('单节点 / 未知作品 / 断链都返回 0', () => {
|
||||
expect(collectLineageSubtreeDepth([node('root', 0, null)], 'root')).toBe(0);
|
||||
expect(collectLineageSubtreeDepth([node('root', 0, null)], 'game-x')).toBe(
|
||||
0,
|
||||
);
|
||||
const orphan = [
|
||||
node('orphan', 1, 'game-missing'),
|
||||
node('child', 2, 'orphan'),
|
||||
];
|
||||
// 父不可用不影响「本作品自己的子树」深度。
|
||||
expect(collectLineageSubtreeDepth(orphan, 'orphan')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectLineageAncestorChain', () => {
|
||||
const nodes = [
|
||||
node('root', 0, null, '根原创'),
|
||||
@@ -303,9 +335,9 @@ describe('collectLineageAncestorChain', () => {
|
||||
];
|
||||
|
||||
it('从根到目标按顺序返回(供「根原创 → 1 代 → … → 当前」展示)', () => {
|
||||
expect(collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId)).toEqual(
|
||||
['root', 'a', 'b'],
|
||||
);
|
||||
expect(
|
||||
collectLineageAncestorChain(nodes, 'b').map((item) => item.gameId),
|
||||
).toEqual(['root', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('父不在可见集合里时链就停在那里(父作品不可用口径一致)', () => {
|
||||
@@ -315,7 +347,9 @@ describe('collectLineageAncestorChain', () => {
|
||||
});
|
||||
|
||||
it('未知节点返回空链,且遇到自环不会死循环', () => {
|
||||
expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual([]);
|
||||
expect(collectLineageAncestorChain(nodes, 'game-missing-chain')).toEqual(
|
||||
[],
|
||||
);
|
||||
const cyclic = [node('x', 1, 'y'), node('y', 0, 'x')];
|
||||
expect(collectLineageAncestorChain(cyclic, 'x')).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -346,6 +346,48 @@ export function buildLineageTreeLayout(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 本作品的**子树深度** = 子树最大代际 − 本作品代际(文档「累计创作世代数」的语义)。
|
||||
*
|
||||
* 用同一份 `/lineage` 数据算,不发新请求;BFS 沿 `parentGameId` 的**反向**关系向下走。
|
||||
* 空树 / 找不到本作品 → 0。注意:节点上限 200 时只在可见集合上计算(截断场景会在页面上标注)。
|
||||
*/
|
||||
export function collectLineageSubtreeDepth<
|
||||
T extends {
|
||||
gameId: string;
|
||||
parentGameId?: string | null;
|
||||
generation: number;
|
||||
},
|
||||
>(nodes: readonly T[], gameId: string): number {
|
||||
const self = nodes.find((node) => node.gameId === gameId);
|
||||
if (!self) return 0;
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const node of nodes) {
|
||||
const parentGameId = node.parentGameId ?? null;
|
||||
if (!parentGameId) continue;
|
||||
const bucket = childrenByParent.get(parentGameId);
|
||||
if (bucket) bucket.push(node.gameId);
|
||||
else childrenByParent.set(parentGameId, [node.gameId]);
|
||||
}
|
||||
const generationById = new Map(
|
||||
nodes.map((node) => [node.gameId, node.generation]),
|
||||
);
|
||||
const seen = new Set<string>([gameId]);
|
||||
const queue = [...(childrenByParent.get(gameId) ?? [])];
|
||||
let maxGeneration = self.generation;
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
maxGeneration = Math.max(
|
||||
maxGeneration,
|
||||
generationById.get(current) ?? self.generation,
|
||||
);
|
||||
queue.push(...(childrenByParent.get(current) ?? []));
|
||||
}
|
||||
return Math.max(0, maxGeneration - self.generation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 血缘链:从根到 `gameId` 的**有序**节点列表(血缘溯源条用)。
|
||||
*
|
||||
@@ -353,7 +395,11 @@ export function buildLineageTreeLayout(
|
||||
* 展示与点击用)。父节点不在可见集合里时链就停在那里(与既有的「父作品不可用」口径一致)。
|
||||
*/
|
||||
export function collectLineageAncestorChain<
|
||||
T extends { gameId: string; parentGameId?: string | null; generation: number },
|
||||
T extends {
|
||||
gameId: string;
|
||||
parentGameId?: string | null;
|
||||
generation: number;
|
||||
},
|
||||
>(nodes: readonly T[], gameId: string): T[] {
|
||||
const byId = new Map(nodes.map((node) => [node.gameId, node]));
|
||||
const chain: T[] = [];
|
||||
|
||||
Reference in New Issue
Block a user