1
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import type { ComponentProps } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
|
||||
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
|
||||
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
VisualNovelAgentWorkspace,
|
||||
} from './VisualNovelAgentWorkspace';
|
||||
} from './visualNovelEntryGeneration';
|
||||
|
||||
function renderWorkspace(
|
||||
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
|
||||
@@ -27,8 +27,42 @@ test('visual novel workspace only exposes one-line input and visual style entry'
|
||||
|
||||
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
|
||||
expect(screen.getByText('视觉画风')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '映画动画' })
|
||||
.querySelector('img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/visual-novel-style-references/cinematic-anime.png');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '水彩绘本' })
|
||||
.querySelector('img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/visual-novel-style-references/watercolor.png');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '像素霓虹' })
|
||||
.querySelector('img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/visual-novel-style-references/pixel-noir.png');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '水墨幻想' })
|
||||
.querySelector('img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/visual-novel-style-references/ink-fantasy.png');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '柔彩校园' })
|
||||
.querySelector('img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/visual-novel-style-references/soft-pastel.png');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '暗色哥特' })
|
||||
.querySelector('img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/visual-novel-style-references/dark-gothic.png');
|
||||
expect(screen.getByText('消耗20光点')).toBeTruthy();
|
||||
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
|
||||
@@ -47,9 +81,7 @@ test('visual novel workspace submits idea and selected visual style as seed text
|
||||
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /生成视觉小说草稿/u }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成视觉小说草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
sourceMode: 'idea',
|
||||
@@ -67,16 +99,15 @@ test('visual novel workspace submits idea and selected visual style as seed text
|
||||
test('visual novel workspace restores idea text from existing session', () => {
|
||||
renderWorkspace({ session: mockVisualNovelSession });
|
||||
|
||||
expect((screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value).toBe(
|
||||
'想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value,
|
||||
).toBe('想做一个雪夜列车和旧电台有关的悬疑视觉小说。');
|
||||
});
|
||||
|
||||
test('visual novel generation helpers build process page data', () => {
|
||||
const payload = {
|
||||
sourceMode: 'idea' as const,
|
||||
seedText:
|
||||
'雨夜书店\n视觉画风:水彩绘本\n画风要求:透明水彩与绘本质感。',
|
||||
seedText: '雨夜书店\n视觉画风:水彩绘本\n画风要求:透明水彩与绘本质感。',
|
||||
sourceAssetIds: [],
|
||||
ideaText: '雨夜书店',
|
||||
visualStyleId: 'watercolor' as const,
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { ArrowLeft, Loader2, Sparkles, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
CreateVisualNovelSessionRequest,
|
||||
VisualNovelAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
|
||||
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { VisualNovelEntryFormPayload } from './visualNovelEntryGeneration';
|
||||
|
||||
type VisualNovelAgentWorkspaceProps = {
|
||||
session?: VisualNovelAgentSessionSnapshot | null;
|
||||
@@ -19,19 +15,6 @@ type VisualNovelAgentWorkspaceProps = {
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
export type VisualNovelEntryFormPayload = Omit<
|
||||
CreateVisualNovelSessionRequest,
|
||||
'seedText' | 'sourceMode' | 'sourceAssetIds'
|
||||
> & {
|
||||
sourceMode: 'idea';
|
||||
seedText: string;
|
||||
sourceAssetIds: string[];
|
||||
ideaText: string;
|
||||
visualStyleId: VisualNovelStyleOptionId;
|
||||
visualStyleLabel: string;
|
||||
visualStylePrompt: string;
|
||||
};
|
||||
|
||||
type VisualNovelFormState = {
|
||||
ideaText: string;
|
||||
visualStyleId: VisualNovelStyleOptionId;
|
||||
@@ -41,31 +24,37 @@ const VISUAL_NOVEL_STYLE_OPTIONS = [
|
||||
{
|
||||
id: 'cinematic-anime',
|
||||
label: '映画动画',
|
||||
imageSrc: '/visual-novel-style-references/cinematic-anime.png',
|
||||
prompt: '电影感动画视觉小说画风,光影层次清晰,角色立绘精致,背景有景深。',
|
||||
},
|
||||
{
|
||||
id: 'watercolor',
|
||||
label: '水彩绘本',
|
||||
imageSrc: '/visual-novel-style-references/watercolor.png',
|
||||
prompt: '透明水彩与绘本质感,色彩柔和,边缘带手绘晕染,适合温柔叙事。',
|
||||
},
|
||||
{
|
||||
id: 'pixel-noir',
|
||||
label: '像素霓虹',
|
||||
imageSrc: '/visual-novel-style-references/pixel-noir.png',
|
||||
prompt: '高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
|
||||
},
|
||||
{
|
||||
id: 'ink-fantasy',
|
||||
label: '水墨幻想',
|
||||
imageSrc: '/visual-novel-style-references/ink-fantasy.png',
|
||||
prompt: '东方水墨幻想画风,留白、墨色层次和淡彩点染并重。',
|
||||
},
|
||||
{
|
||||
id: 'soft-pastel',
|
||||
label: '柔彩校园',
|
||||
imageSrc: '/visual-novel-style-references/soft-pastel.png',
|
||||
prompt: '柔和粉彩校园画风,干净明亮,角色表情细腻,日常氛围轻盈。',
|
||||
},
|
||||
{
|
||||
id: 'dark-gothic',
|
||||
label: '暗色哥特',
|
||||
imageSrc: '/visual-novel-style-references/dark-gothic.png',
|
||||
prompt: '暗色哥特视觉小说画风,深色场景、烛光高光和华丽服装细节。',
|
||||
},
|
||||
] as const;
|
||||
@@ -136,11 +125,13 @@ function buildVisualNovelSeedText(
|
||||
function VisualNovelStyleButton({
|
||||
active,
|
||||
disabled,
|
||||
imageSrc,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
imageSrc: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -157,7 +148,16 @@ function VisualNovelStyleButton({
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
|
||||
)}
|
||||
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.02)_0%,rgba(255,255,255,0.18)_44%,rgba(255,255,255,0.82)_100%)]" />
|
||||
{active ? (
|
||||
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
|
||||
@@ -299,6 +299,7 @@ export function VisualNovelAgentWorkspace({
|
||||
key={option.id}
|
||||
active={formState.visualStyleId === option.id}
|
||||
disabled={isBusy}
|
||||
imageSrc={option.imageSrc}
|
||||
label={option.label}
|
||||
onClick={() =>
|
||||
setFormState((current) => ({
|
||||
@@ -348,158 +349,4 @@ export function VisualNovelAgentWorkspace({
|
||||
);
|
||||
}
|
||||
|
||||
export function buildVisualNovelEntryGenerationAnchorEntries(
|
||||
payload: VisualNovelEntryFormPayload | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'visual-novel-idea',
|
||||
label: '一句话',
|
||||
value: payload.ideaText,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-style',
|
||||
label: '视觉画风',
|
||||
value: payload.visualStyleLabel,
|
||||
},
|
||||
].filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildVisualNovelEntryGenerationProgress(
|
||||
startedAtMs: number | null,
|
||||
phase: 'generating' | 'ready' | 'failed',
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress {
|
||||
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
||||
const timeline: [
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
] = [
|
||||
{
|
||||
id: 'visual-novel-session',
|
||||
label: '创建创作会话',
|
||||
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
|
||||
weight: 24,
|
||||
durationMs: 5_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-draft',
|
||||
label: '生成故事底稿',
|
||||
detail: '整理世界观、角色、场景和剧情阶段。',
|
||||
weight: 56,
|
||||
durationMs: 22_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-ready',
|
||||
label: '准备草稿页',
|
||||
detail: '校验可编辑字段并进入草稿页。',
|
||||
weight: 20,
|
||||
durationMs: 4_000,
|
||||
},
|
||||
];
|
||||
let elapsedBeforeStep = 0;
|
||||
const activeStepIndex =
|
||||
phase === 'ready'
|
||||
? timeline.length - 1
|
||||
: timeline.findIndex((step) => {
|
||||
const elapsedInStep = elapsedMs - elapsedBeforeStep;
|
||||
const isActive = elapsedInStep < step.durationMs;
|
||||
if (!isActive) {
|
||||
elapsedBeforeStep += step.durationMs;
|
||||
}
|
||||
return isActive;
|
||||
});
|
||||
const normalizedActiveStepIndex =
|
||||
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
|
||||
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
|
||||
const activeElapsed =
|
||||
elapsedMs -
|
||||
timeline
|
||||
.slice(0, normalizedActiveStepIndex)
|
||||
.reduce((sum, step) => sum + step.durationMs, 0);
|
||||
const activeRatio =
|
||||
phase === 'ready'
|
||||
? 1
|
||||
: phase === 'failed'
|
||||
? 0
|
||||
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
|
||||
const completedWeight = timeline
|
||||
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const overallProgress =
|
||||
phase === 'ready'
|
||||
? 100
|
||||
: phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
|
||||
|
||||
return {
|
||||
phaseId: phase,
|
||||
phaseLabel:
|
||||
phase === 'ready'
|
||||
? '生成完成'
|
||||
: phase === 'failed'
|
||||
? '生成失败'
|
||||
: activeStep.label,
|
||||
phaseDetail:
|
||||
phase === 'ready'
|
||||
? '视觉小说草稿已准备完成。'
|
||||
: phase === 'failed'
|
||||
? '草稿生成失败,请返回入口页调整后重试。'
|
||||
: activeStep.detail,
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
|
||||
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
|
||||
activeStepIndex: normalizedActiveStepIndex,
|
||||
steps: timeline.map((step, index) => {
|
||||
const isCompleted =
|
||||
phase === 'ready' || index < normalizedActiveStepIndex;
|
||||
const isActive =
|
||||
phase !== 'failed' &&
|
||||
!isCompleted &&
|
||||
index === normalizedActiveStepIndex;
|
||||
const status: 'completed' | 'active' | 'pending' = isCompleted
|
||||
? 'completed'
|
||||
: isActive
|
||||
? 'active'
|
||||
: 'pending';
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
|
||||
total: 1,
|
||||
status,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default VisualNovelAgentWorkspace;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CreateVisualNovelSessionRequest } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
|
||||
|
||||
export type VisualNovelEntryFormPayload = Omit<
|
||||
CreateVisualNovelSessionRequest,
|
||||
'seedText' | 'sourceMode' | 'sourceAssetIds'
|
||||
> & {
|
||||
sourceMode: 'idea';
|
||||
seedText: string;
|
||||
sourceAssetIds: string[];
|
||||
ideaText: string;
|
||||
visualStyleId: VisualNovelStyleOptionId;
|
||||
visualStyleLabel: string;
|
||||
visualStylePrompt: string;
|
||||
};
|
||||
|
||||
type VisualNovelStyleOptionId =
|
||||
| 'cinematic-anime'
|
||||
| 'watercolor'
|
||||
| 'pixel-noir'
|
||||
| 'ink-fantasy'
|
||||
| 'soft-pastel'
|
||||
| 'dark-gothic';
|
||||
|
||||
export function buildVisualNovelEntryGenerationAnchorEntries(
|
||||
payload: VisualNovelEntryFormPayload | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'visual-novel-idea',
|
||||
label: '一句话',
|
||||
value: payload.ideaText,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-style',
|
||||
label: '视觉画风',
|
||||
value: payload.visualStyleLabel,
|
||||
},
|
||||
].filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildVisualNovelEntryGenerationProgress(
|
||||
startedAtMs: number | null,
|
||||
phase: 'generating' | 'ready' | 'failed',
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress {
|
||||
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
||||
const timeline: [
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
] = [
|
||||
{
|
||||
id: 'visual-novel-session',
|
||||
label: '创建创作会话',
|
||||
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
|
||||
weight: 24,
|
||||
durationMs: 5_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-draft',
|
||||
label: '生成故事底稿',
|
||||
detail: '整理世界观、角色、场景和剧情阶段。',
|
||||
weight: 56,
|
||||
durationMs: 22_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-ready',
|
||||
label: '准备草稿页',
|
||||
detail: '校验可编辑字段并进入草稿页。',
|
||||
weight: 20,
|
||||
durationMs: 4_000,
|
||||
},
|
||||
];
|
||||
let elapsedBeforeStep = 0;
|
||||
const activeStepIndex =
|
||||
phase === 'ready'
|
||||
? timeline.length - 1
|
||||
: timeline.findIndex((step) => {
|
||||
const elapsedInStep = elapsedMs - elapsedBeforeStep;
|
||||
const isActive = elapsedInStep < step.durationMs;
|
||||
if (!isActive) {
|
||||
elapsedBeforeStep += step.durationMs;
|
||||
}
|
||||
return isActive;
|
||||
});
|
||||
const normalizedActiveStepIndex =
|
||||
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
|
||||
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
|
||||
const activeElapsed =
|
||||
elapsedMs -
|
||||
timeline
|
||||
.slice(0, normalizedActiveStepIndex)
|
||||
.reduce((sum, step) => sum + step.durationMs, 0);
|
||||
const activeRatio =
|
||||
phase === 'ready'
|
||||
? 1
|
||||
: phase === 'failed'
|
||||
? 0
|
||||
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
|
||||
const completedWeight = timeline
|
||||
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const overallProgress =
|
||||
phase === 'ready'
|
||||
? 100
|
||||
: phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
|
||||
|
||||
return {
|
||||
phaseId: phase,
|
||||
phaseLabel:
|
||||
phase === 'ready'
|
||||
? '生成完成'
|
||||
: phase === 'failed'
|
||||
? '生成失败'
|
||||
: activeStep.label,
|
||||
phaseDetail:
|
||||
phase === 'ready'
|
||||
? '视觉小说草稿已准备完成。'
|
||||
: phase === 'failed'
|
||||
? '草稿生成失败,请返回入口页调整后重试。'
|
||||
: activeStep.detail,
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
|
||||
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
|
||||
activeStepIndex: normalizedActiveStepIndex,
|
||||
steps: timeline.map((step, index) => {
|
||||
const isCompleted =
|
||||
phase === 'ready' || index < normalizedActiveStepIndex;
|
||||
const isActive =
|
||||
phase !== 'failed' &&
|
||||
!isCompleted &&
|
||||
index === normalizedActiveStepIndex;
|
||||
const status: 'completed' | 'active' | 'pending' = isCompleted
|
||||
? 'completed'
|
||||
: isActive
|
||||
? 'active'
|
||||
: 'pending';
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
|
||||
total: 1,
|
||||
status,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user