refactor: extract canvas detail archive helpers
This commit is contained in:
parent
d05eabe258
commit
6a19c43094
@ -156,6 +156,13 @@ import {
|
||||
buildDetailReviewAppendMarkdown,
|
||||
buildDetailReviewPlans,
|
||||
} from "@/components/canvas/chat-panel/canvas-detail-markdown";
|
||||
import {
|
||||
createDetailArchiveSnapshot,
|
||||
patchDetailArchiveList,
|
||||
prependDetailArchive,
|
||||
replaceSavedDetailArchive,
|
||||
sanitizeDetailArchiveList,
|
||||
} from "@/components/canvas/chat-panel/canvas-detail-archive";
|
||||
import {
|
||||
clampChatRequestText,
|
||||
deriveSessionTitleFromMessages,
|
||||
@ -3200,96 +3207,7 @@ export function CanvasChatPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Array.isArray(result.data) ? (result.data as DetailArchive[]) : [];
|
||||
const sanitized = parsed
|
||||
.filter((item) => item?.id && item?.createdAt)
|
||||
.map((item) => ({
|
||||
id: String(item.id),
|
||||
title: String(item.title || "未命名详情项目"),
|
||||
prompt: String(item.prompt || ""),
|
||||
generationMode: normalizeDetailGenerationMode((item as any).generationMode),
|
||||
brief: {
|
||||
productSpu: String(item.brief?.productSpu || ""),
|
||||
demandType: String(item.brief?.demandType || ""),
|
||||
productName: String(item.brief?.productName || ""),
|
||||
targetAudience: String(item.brief?.targetAudience || ""),
|
||||
mainUser: String(item.brief?.mainUser || ""),
|
||||
usageScenario: String(item.brief?.usageScenario || ""),
|
||||
triggerMoment: String(item.brief?.triggerMoment || ""),
|
||||
coreNeed: String(item.brief?.coreNeed || ""),
|
||||
riskConcern: String(item.brief?.riskConcern || ""),
|
||||
expectedResult: String(item.brief?.expectedResult || ""),
|
||||
coreSellingPoint: String(item.brief?.coreSellingPoint || ""),
|
||||
positioningStatement: String(item.brief?.positioningStatement || ""),
|
||||
userVoice: String(item.brief?.userVoice || ""),
|
||||
competitorReference: String(item.brief?.competitorReference || ""),
|
||||
priceBand: String(item.brief?.priceBand || ""),
|
||||
brandTone: String(item.brief?.brandTone || ""),
|
||||
bannedWords: String(item.brief?.bannedWords || ""),
|
||||
},
|
||||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||||
const slot = item.slots?.[index];
|
||||
if (!slot?.url) return null;
|
||||
return {
|
||||
url: String(slot.url),
|
||||
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
|
||||
contentType: String(slot.contentType || "image/jpeg"),
|
||||
} as PickedObjectAttachment;
|
||||
}),
|
||||
createdAt:
|
||||
typeof item.createdAt === "number"
|
||||
? item.createdAt
|
||||
: (() => {
|
||||
const timestamp = Date.parse(String(item.createdAt || ""));
|
||||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||
})(),
|
||||
updatedAt:
|
||||
typeof (item as any).updatedAt === "number"
|
||||
? Number((item as any).updatedAt)
|
||||
: (() => {
|
||||
const timestamp = Date.parse(String((item as any).updatedAt || ""));
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
})(),
|
||||
summary: item.summary ? String(item.summary) : undefined,
|
||||
imageCount: Number(item.imageCount || 0) || undefined,
|
||||
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
|
||||
uiSurfaceSnapshot:
|
||||
item.uiSurfaceSnapshot &&
|
||||
typeof item.uiSurfaceSnapshot === "object" &&
|
||||
!Array.isArray(item.uiSurfaceSnapshot)
|
||||
? (item.uiSurfaceSnapshot as UiSurfaceSnapshot)
|
||||
: null,
|
||||
uiSurfaceLatestReview:
|
||||
item.uiSurfaceLatestReview &&
|
||||
typeof item.uiSurfaceLatestReview === "object" &&
|
||||
!Array.isArray(item.uiSurfaceLatestReview)
|
||||
? {
|
||||
screenIndex: Number((item.uiSurfaceLatestReview as any).screenIndex || 0) || undefined,
|
||||
action:
|
||||
(item.uiSurfaceLatestReview as any).action === "protocol_patch" ||
|
||||
(item.uiSurfaceLatestReview as any).action === "regenerate_image" ||
|
||||
(item.uiSurfaceLatestReview as any).action === "pass"
|
||||
? (item.uiSurfaceLatestReview as any).action
|
||||
: undefined,
|
||||
passed:
|
||||
typeof (item.uiSurfaceLatestReview as any).passed === "boolean"
|
||||
? (item.uiSurfaceLatestReview as any).passed
|
||||
: undefined,
|
||||
issues: Array.isArray((item.uiSurfaceLatestReview as any).issues)
|
||||
? (item.uiSurfaceLatestReview as any).issues
|
||||
.map((issue: unknown) => String(issue || "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 10)
|
||||
: [],
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt)
|
||||
);
|
||||
|
||||
setDetailArchives(sanitized.slice(0, 30));
|
||||
setDetailArchives(sanitizeDetailArchiveList(result.data));
|
||||
} catch (error) {
|
||||
if (isCancelled) return;
|
||||
console.error("[CanvasChat] Failed to load detail archives:", error);
|
||||
@ -5187,13 +5105,7 @@ export function CanvasChatPanel({
|
||||
|
||||
const updateLocalDetailArchive = useCallback(
|
||||
(archiveId: string, patch: Partial<DetailArchive>) => {
|
||||
setDetailArchives((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === archiveId
|
||||
? { ...item, ...patch, updatedAt: patch.updatedAt || Date.now() }
|
||||
: item
|
||||
)
|
||||
);
|
||||
setDetailArchives((prev) => patchDetailArchiveList(prev, archiveId, patch));
|
||||
},
|
||||
[]
|
||||
);
|
||||
@ -5270,52 +5182,17 @@ export function CanvasChatPanel({
|
||||
uiSurfaceSnapshot?: UiSurfaceSnapshot | null;
|
||||
uiSurfaceLatestReview?: DetailArchive["uiSurfaceLatestReview"];
|
||||
}): Promise<DetailArchive | null> => {
|
||||
const archiveId = generateUUID();
|
||||
const promptText = params.prompt.replace(/\s+/g, " ").trim();
|
||||
const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim();
|
||||
const demandSeed = String(
|
||||
params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || ""
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const createdAt = Date.now();
|
||||
const archiveTitle =
|
||||
spuSeed && demandSeed
|
||||
? `${spuSeed}+${demandSeed}`
|
||||
: spuSeed || demandSeed || params.brief.productName || "AI详情项目";
|
||||
|
||||
const archive: DetailArchive = {
|
||||
id: archiveId,
|
||||
title: archiveTitle,
|
||||
prompt: promptText,
|
||||
generationMode: params.generationMode,
|
||||
brief: { ...params.brief },
|
||||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||||
const slot = params.slots[index];
|
||||
return slot
|
||||
? {
|
||||
url: slot.url,
|
||||
name: slot.name,
|
||||
contentType: slot.contentType,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
const archive = createDetailArchiveSnapshot({
|
||||
...params,
|
||||
archiveId: generateUUID(),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
summary: params.summary,
|
||||
imageCount: params.imageCount,
|
||||
coverUrl: params.coverUrl,
|
||||
uiSurfaceSnapshot: params.uiSurfaceSnapshot || null,
|
||||
uiSurfaceLatestReview: params.uiSurfaceLatestReview || null,
|
||||
};
|
||||
});
|
||||
|
||||
setDetailArchives((prev) => [archive, ...prev].slice(0, 30));
|
||||
setDetailArchives((prev) => prependDetailArchive(prev, archive));
|
||||
const saved = await createDetailArchiveInDatabase(archive);
|
||||
if (saved?.id) {
|
||||
setDetailArchives((prev) => {
|
||||
const next = [saved, ...prev.filter((item) => item.id !== archive.id && item.id !== saved.id)];
|
||||
return next.slice(0, 30);
|
||||
});
|
||||
setDetailArchives((prev) => replaceSavedDetailArchive(prev, archive.id, saved));
|
||||
return saved;
|
||||
}
|
||||
return archive;
|
||||
|
||||
187
components/canvas/chat-panel/canvas-detail-archive.ts
Normal file
187
components/canvas/chat-panel/canvas-detail-archive.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol";
|
||||
import {
|
||||
type DetailArchive,
|
||||
type DetailBriefForm,
|
||||
type DetailGenerationMode,
|
||||
DETAIL_SLOT_CONFIGS,
|
||||
normalizeDetailGenerationMode,
|
||||
} from "@/components/canvas/chat-panel/canvas-detail-config";
|
||||
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
|
||||
|
||||
const DETAIL_ARCHIVE_LIST_LIMIT = 30;
|
||||
|
||||
function toTimestamp(value: unknown, fallback?: number): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
const parsed = Date.parse(String(value || ""));
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toDetailBriefForm(value: Partial<DetailBriefForm> | null | undefined): DetailBriefForm {
|
||||
return {
|
||||
productSpu: String(value?.productSpu || ""),
|
||||
demandType: String(value?.demandType || ""),
|
||||
productName: String(value?.productName || ""),
|
||||
targetAudience: String(value?.targetAudience || ""),
|
||||
mainUser: String(value?.mainUser || ""),
|
||||
usageScenario: String(value?.usageScenario || ""),
|
||||
triggerMoment: String(value?.triggerMoment || ""),
|
||||
coreNeed: String(value?.coreNeed || ""),
|
||||
riskConcern: String(value?.riskConcern || ""),
|
||||
expectedResult: String(value?.expectedResult || ""),
|
||||
coreSellingPoint: String(value?.coreSellingPoint || ""),
|
||||
positioningStatement: String(value?.positioningStatement || ""),
|
||||
userVoice: String(value?.userVoice || ""),
|
||||
competitorReference: String(value?.competitorReference || ""),
|
||||
priceBand: String(value?.priceBand || ""),
|
||||
brandTone: String(value?.brandTone || ""),
|
||||
bannedWords: String(value?.bannedWords || ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeDetailArchiveList(value: unknown): DetailArchive[] {
|
||||
const parsed = Array.isArray(value) ? (value as DetailArchive[]) : [];
|
||||
return parsed
|
||||
.filter((item) => item?.id && item?.createdAt)
|
||||
.map((item) => {
|
||||
const createdAt = toTimestamp(item.createdAt, Date.now()) || Date.now();
|
||||
return {
|
||||
id: String(item.id),
|
||||
title: String(item.title || "未命名详情项目"),
|
||||
prompt: String(item.prompt || ""),
|
||||
generationMode: normalizeDetailGenerationMode((item as any).generationMode),
|
||||
brief: toDetailBriefForm(item.brief),
|
||||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||||
const slot = item.slots?.[index];
|
||||
if (!slot?.url) return null;
|
||||
return {
|
||||
url: String(slot.url),
|
||||
name: String(slot.name || DETAIL_SLOT_CONFIGS[index]?.label || `素材${index + 1}`),
|
||||
contentType: String(slot.contentType || "image/jpeg"),
|
||||
} as PickedObjectAttachment;
|
||||
}),
|
||||
createdAt,
|
||||
updatedAt: toTimestamp((item as any).updatedAt),
|
||||
summary: item.summary ? String(item.summary) : undefined,
|
||||
imageCount: Number(item.imageCount || 0) || undefined,
|
||||
coverUrl: item.coverUrl ? String(item.coverUrl) : undefined,
|
||||
uiSurfaceSnapshot:
|
||||
item.uiSurfaceSnapshot &&
|
||||
typeof item.uiSurfaceSnapshot === "object" &&
|
||||
!Array.isArray(item.uiSurfaceSnapshot)
|
||||
? (item.uiSurfaceSnapshot as UiSurfaceSnapshot)
|
||||
: null,
|
||||
uiSurfaceLatestReview:
|
||||
item.uiSurfaceLatestReview &&
|
||||
typeof item.uiSurfaceLatestReview === "object" &&
|
||||
!Array.isArray(item.uiSurfaceLatestReview)
|
||||
? {
|
||||
screenIndex:
|
||||
Number((item.uiSurfaceLatestReview as any).screenIndex || 0) || undefined,
|
||||
action:
|
||||
(item.uiSurfaceLatestReview as any).action === "protocol_patch" ||
|
||||
(item.uiSurfaceLatestReview as any).action === "regenerate_image" ||
|
||||
(item.uiSurfaceLatestReview as any).action === "pass"
|
||||
? (item.uiSurfaceLatestReview as any).action
|
||||
: undefined,
|
||||
passed:
|
||||
typeof (item.uiSurfaceLatestReview as any).passed === "boolean"
|
||||
? (item.uiSurfaceLatestReview as any).passed
|
||||
: undefined,
|
||||
issues: Array.isArray((item.uiSurfaceLatestReview as any).issues)
|
||||
? (item.uiSurfaceLatestReview as any).issues
|
||||
.map((issue: unknown) => String(issue || "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 10)
|
||||
: [],
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.updatedAt || b.createdAt) - (a.updatedAt || a.createdAt))
|
||||
.slice(0, DETAIL_ARCHIVE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
export function patchDetailArchiveList(
|
||||
archives: DetailArchive[],
|
||||
archiveId: string,
|
||||
patch: Partial<DetailArchive>,
|
||||
now = Date.now()
|
||||
): DetailArchive[] {
|
||||
return archives.map((item) =>
|
||||
item.id === archiveId ? { ...item, ...patch, updatedAt: patch.updatedAt || now } : item
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceSavedDetailArchive(
|
||||
archives: DetailArchive[],
|
||||
draftArchiveId: string,
|
||||
savedArchive: DetailArchive
|
||||
): DetailArchive[] {
|
||||
return [
|
||||
savedArchive,
|
||||
...archives.filter((item) => item.id !== draftArchiveId && item.id !== savedArchive.id),
|
||||
].slice(0, DETAIL_ARCHIVE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
export function prependDetailArchive(
|
||||
archives: DetailArchive[],
|
||||
archive: DetailArchive
|
||||
): DetailArchive[] {
|
||||
return [archive, ...archives].slice(0, DETAIL_ARCHIVE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
export function createDetailArchiveSnapshot(params: {
|
||||
archiveId: string;
|
||||
createdAt: number;
|
||||
summary?: string;
|
||||
imageCount?: number;
|
||||
coverUrl?: string;
|
||||
prompt: string;
|
||||
generationMode: DetailGenerationMode;
|
||||
brief: DetailBriefForm;
|
||||
slots: Array<PickedObjectAttachment | null>;
|
||||
uiSurfaceSnapshot?: UiSurfaceSnapshot | null;
|
||||
uiSurfaceLatestReview?: DetailArchive["uiSurfaceLatestReview"];
|
||||
}): DetailArchive {
|
||||
const promptText = params.prompt.replace(/\s+/g, " ").trim();
|
||||
const spuSeed = String(params.brief.productSpu || "").replace(/\s+/g, "").trim();
|
||||
const demandSeed = String(
|
||||
params.brief.demandType || params.brief.coreNeed || params.brief.usageScenario || ""
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const archiveTitle =
|
||||
spuSeed && demandSeed
|
||||
? `${spuSeed}+${demandSeed}`
|
||||
: spuSeed || demandSeed || params.brief.productName || "AI详情项目";
|
||||
|
||||
return {
|
||||
id: params.archiveId,
|
||||
title: archiveTitle,
|
||||
prompt: promptText,
|
||||
generationMode: params.generationMode,
|
||||
brief: { ...params.brief },
|
||||
slots: DETAIL_SLOT_CONFIGS.map((_, index) => {
|
||||
const slot = params.slots[index];
|
||||
return slot
|
||||
? {
|
||||
url: slot.url,
|
||||
name: slot.name,
|
||||
contentType: slot.contentType,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
createdAt: params.createdAt,
|
||||
updatedAt: params.createdAt,
|
||||
summary: params.summary,
|
||||
imageCount: params.imageCount,
|
||||
coverUrl: params.coverUrl,
|
||||
uiSurfaceSnapshot: params.uiSurfaceSnapshot || null,
|
||||
uiSurfaceLatestReview: params.uiSurfaceLatestReview || null,
|
||||
};
|
||||
}
|
||||
@ -26,6 +26,13 @@ import {
|
||||
sanitizeDetailImageModelPrompt,
|
||||
splitDetailKeywords,
|
||||
} from "@/components/canvas/chat-panel/canvas-detail-config";
|
||||
import {
|
||||
createDetailArchiveSnapshot,
|
||||
patchDetailArchiveList,
|
||||
prependDetailArchive,
|
||||
replaceSavedDetailArchive,
|
||||
sanitizeDetailArchiveList,
|
||||
} from "@/components/canvas/chat-panel/canvas-detail-archive";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
||||
test.describe("canvas chat pure helpers", () => {
|
||||
@ -259,4 +266,142 @@ test.describe("canvas chat pure helpers", () => {
|
||||
})
|
||||
).toContain("失败 1 条,已跳过。");
|
||||
});
|
||||
|
||||
test("sanitizes and sorts detail archives from persisted payloads", () => {
|
||||
const archives = sanitizeDetailArchiveList([
|
||||
{
|
||||
id: "older",
|
||||
title: "",
|
||||
prompt: " old prompt ",
|
||||
generationMode: "invalid",
|
||||
brief: {
|
||||
productName: "旧产品",
|
||||
targetAudience: "人群",
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
url: "https://cdn.example.com/product.png",
|
||||
name: "",
|
||||
contentType: "",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
uiSurfaceLatestReview: {
|
||||
screenIndex: 2,
|
||||
action: "unsafe",
|
||||
passed: true,
|
||||
issues: [" issue-a ", "", "issue-b"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "newer",
|
||||
title: "新项目",
|
||||
prompt: "new prompt",
|
||||
generationMode: "agent_layout",
|
||||
brief: {
|
||||
coreNeed: "核心需求",
|
||||
},
|
||||
slots: [],
|
||||
createdAt: "2026-01-03T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
createdAt: "2026-01-04T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(archives.map((archive) => archive.id)).toEqual(["newer", "older"]);
|
||||
expect(archives[0]?.generationMode).toBe("agent_layout");
|
||||
expect(archives[1]).toMatchObject({
|
||||
title: "未命名详情项目",
|
||||
generationMode: "classic",
|
||||
brief: {
|
||||
productName: "旧产品",
|
||||
coreNeed: "",
|
||||
},
|
||||
});
|
||||
expect(archives[1]?.slots[0]).toMatchObject({
|
||||
url: "https://cdn.example.com/product.png",
|
||||
name: "产品图1",
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
expect(archives[1]?.uiSurfaceLatestReview).toMatchObject({
|
||||
screenIndex: 2,
|
||||
action: undefined,
|
||||
passed: true,
|
||||
issues: ["issue-a", "issue-b"],
|
||||
});
|
||||
});
|
||||
|
||||
test("creates and updates detail archive lists with stable limits", () => {
|
||||
const archive = createDetailArchiveSnapshot({
|
||||
archiveId: "draft",
|
||||
createdAt: 1000,
|
||||
prompt: " 第一行\n第二行 ",
|
||||
generationMode: "classic",
|
||||
summary: "摘要",
|
||||
imageCount: 2,
|
||||
coverUrl: "https://cdn.example.com/cover.png",
|
||||
brief: {
|
||||
productSpu: " SPU-001 ",
|
||||
demandType: "",
|
||||
productName: "产品名",
|
||||
targetAudience: "",
|
||||
mainUser: "",
|
||||
usageScenario: " 客厅清洁 ",
|
||||
triggerMoment: "",
|
||||
coreNeed: "",
|
||||
riskConcern: "",
|
||||
expectedResult: "",
|
||||
coreSellingPoint: "",
|
||||
positioningStatement: "",
|
||||
userVoice: "",
|
||||
competitorReference: "",
|
||||
priceBand: "",
|
||||
brandTone: "",
|
||||
bannedWords: "",
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
name: "产品",
|
||||
url: "https://cdn.example.com/product.png",
|
||||
contentType: "image/png",
|
||||
},
|
||||
null,
|
||||
],
|
||||
});
|
||||
|
||||
expect(archive).toMatchObject({
|
||||
id: "draft",
|
||||
title: "SPU-001+客厅清洁",
|
||||
prompt: "第一行 第二行",
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
summary: "摘要",
|
||||
imageCount: 2,
|
||||
coverUrl: "https://cdn.example.com/cover.png",
|
||||
});
|
||||
expect(archive.slots[0]).toMatchObject({
|
||||
name: "产品",
|
||||
url: "https://cdn.example.com/product.png",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const patched = patchDetailArchiveList([archive], "draft", { summary: "新摘要" }, 2000);
|
||||
expect(patched[0]).toMatchObject({ summary: "新摘要", updatedAt: 2000 });
|
||||
|
||||
const saved = { ...archive, id: "saved", updatedAt: 3000 };
|
||||
expect(replaceSavedDetailArchive([archive], "draft", saved).map((item) => item.id)).toEqual([
|
||||
"saved",
|
||||
]);
|
||||
|
||||
const manyArchives = Array.from({ length: 35 }, (_, index) => ({
|
||||
...archive,
|
||||
id: `archive-${index}`,
|
||||
createdAt: index,
|
||||
updatedAt: index,
|
||||
}));
|
||||
expect(prependDetailArchive(manyArchives, saved)).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user