's'
This commit is contained in:
332
web-console/components/console/ConfigAdvisorPanel.tsx
Normal file
332
web-console/components/console/ConfigAdvisorPanel.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, History, Loader2, RefreshCw, RotateCcw, Sparkles } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
|
||||
type ConfigIssue = {
|
||||
level: string;
|
||||
code: string;
|
||||
message: string;
|
||||
line?: number;
|
||||
};
|
||||
|
||||
type ConfigStat = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ConfigAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type ConfigBackup = {
|
||||
id: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
label: string;
|
||||
created_at: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ConfigInsights = {
|
||||
kind?: string;
|
||||
source?: string;
|
||||
summary?: string;
|
||||
issues?: ConfigIssue[];
|
||||
stats?: ConfigStat[];
|
||||
actions?: ConfigAction[];
|
||||
backups?: ConfigBackup[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
lang: Lang;
|
||||
rootId: string;
|
||||
filePath: string;
|
||||
fileContent: string;
|
||||
refreshKey: number;
|
||||
busy: string | null;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
notify: (message: string) => void;
|
||||
onChangeContent: (content: string) => void;
|
||||
onRefreshListing?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
function tr(lang: Lang, zh: string, en: string) {
|
||||
return lang === "zh" ? zh : en;
|
||||
}
|
||||
|
||||
function formatBytes(size: number) {
|
||||
if (!size) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = size;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function issueTone(level: string) {
|
||||
if (level === "error") return "border-rose-500/30 bg-rose-500/10 text-rose-100";
|
||||
if (level === "warning") return "border-amber-500/30 bg-amber-500/10 text-amber-100";
|
||||
return "border-cyan-500/30 bg-cyan-500/10 text-cyan-100";
|
||||
}
|
||||
|
||||
export function ConfigAdvisorPanel({
|
||||
lang,
|
||||
rootId,
|
||||
filePath,
|
||||
fileContent,
|
||||
refreshKey,
|
||||
busy,
|
||||
fetchJson,
|
||||
notify,
|
||||
onChangeContent,
|
||||
onRefreshListing,
|
||||
}: Props) {
|
||||
const [insights, setInsights] = useState<ConfigInsights | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [draftLoading, setDraftLoading] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const [restoreBusy, setRestoreBusy] = useState<string | null>(null);
|
||||
|
||||
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
|
||||
|
||||
const loadSavedInsights = useCallback(async () => {
|
||||
if (!rootId || !filePath) {
|
||||
setInsights(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ root: rootId, path: filePath });
|
||||
const data = await fetchJson<ConfigInsights>(`/config_insights?${params.toString()}`);
|
||||
setInsights(data);
|
||||
} catch (reason) {
|
||||
setInsights(null);
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchJson, filePath, notify, rootId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSavedInsights();
|
||||
}, [loadSavedInsights, refreshKey]);
|
||||
|
||||
const analyzeDraft = async () => {
|
||||
if (!rootId || !filePath) return;
|
||||
setDraftLoading(true);
|
||||
try {
|
||||
const data = await fetchJson<ConfigInsights>("/config_insights", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ root: rootId, path: filePath, content: fileContent }),
|
||||
});
|
||||
setInsights(data);
|
||||
notify(t("已分析当前编辑内容", "Analyzed current editor draft"));
|
||||
} catch (reason) {
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
setDraftLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAction = async (actionId: string) => {
|
||||
if (!rootId || !filePath) return;
|
||||
setActionBusy(actionId);
|
||||
try {
|
||||
const data = await fetchJson<{ message?: string; content?: string; insights?: ConfigInsights }>(
|
||||
"/config_optimize",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ root: rootId, path: filePath, content: fileContent, action: actionId }),
|
||||
},
|
||||
);
|
||||
if (typeof data.content === "string") onChangeContent(data.content);
|
||||
if (data.insights) {
|
||||
setInsights((prev) => ({ ...prev, ...data.insights, backups: prev?.backups || [] }));
|
||||
}
|
||||
notify(data.message || t("已应用优化", "Optimization applied"));
|
||||
} catch (reason) {
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreFromBackup = async (backupId: string) => {
|
||||
if (!rootId || !filePath) return;
|
||||
setRestoreBusy(backupId);
|
||||
try {
|
||||
const data = await fetchJson<{ message?: string; content?: string }>("/config_restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ root: rootId, path: filePath, backup_id: backupId }),
|
||||
});
|
||||
if (typeof data.content === "string") onChangeContent(data.content);
|
||||
await Promise.resolve(onRefreshListing?.());
|
||||
await loadSavedInsights();
|
||||
notify(data.message || t("已恢复历史版本", "Backup restored"));
|
||||
} catch (reason) {
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
setRestoreBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-400">
|
||||
{t("选择文件后显示诊断、回滚和一键优化。", "Pick a file to see diagnostics, rollback, and one-click optimizations.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const issues = insights?.issues || [];
|
||||
const actions = insights?.actions || [];
|
||||
const backups = insights?.backups || [];
|
||||
const stats = insights?.stats || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-cyan-500/15 bg-gradient-to-br from-cyan-500/[0.08] via-black/25 to-transparent p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Sparkles className="h-4 w-4 text-cyan-300" />
|
||||
<h3 className="text-sm font-semibold">{t("配置治理", "Config governance")}</h3>
|
||||
{insights?.source ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
|
||||
{insights.source}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
{insights?.summary ||
|
||||
t("校验配置风险、查看历史快照,并将常见优化应用到编辑器。", "Validate risks, inspect snapshots, and apply optimizations into the editor.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || loading}
|
||||
onClick={() => void loadSavedInsights()}
|
||||
className="btn-focus inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/25 px-3 py-2 text-xs text-slate-100 disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{t("刷新已保存状态", "Refresh saved state")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || draftLoading}
|
||||
onClick={() => void analyzeDraft()}
|
||||
className="btn-focus inline-flex items-center gap-2 rounded-xl border border-cyan-500/30 bg-cyan-500/15 px-3 py-2 text-xs text-cyan-100 disabled:opacity-40"
|
||||
>
|
||||
{draftLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <AlertTriangle className="h-3.5 w-3.5" />}
|
||||
{t("分析当前编辑器", "Analyze editor draft")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.slice(0, 8).map((item) => (
|
||||
<div key={item.key} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-slate-500">{item.label}</div>
|
||||
<div className="mt-1 truncate font-mono text-sm text-white">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-300" />
|
||||
<h4 className="text-sm font-semibold">{t("风险与建议", "Risks & guidance")}</h4>
|
||||
</div>
|
||||
{issues.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{issues.map((item, index) => (
|
||||
<div key={`${item.code}-${index}`} className={`rounded-xl border px-3 py-2 text-xs ${issueTone(item.level)}`}>
|
||||
<div className="font-medium">
|
||||
{item.message}
|
||||
{item.line ? ` (L${item.line})` : ""}
|
||||
</div>
|
||||
<div className="mt-1 font-mono opacity-70">{item.code}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-emerald-300/90">{t("当前没有发现明显配置风险。", "No obvious config risks were detected.")}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{actions.length ? (
|
||||
actions.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={!!busy || !!actionBusy}
|
||||
onClick={() => void applyAction(item.id)}
|
||||
className="btn-focus rounded-xl border border-violet-500/30 bg-violet-500/15 px-3 py-2 text-xs text-violet-100 disabled:opacity-40"
|
||||
title={item.description}
|
||||
>
|
||||
{actionBusy === item.id ? t("处理中...", "Working...") : item.label}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">{t("当前文件没有可自动应用的预设。", "No auto-applicable presets for this file.")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<History className="h-4 w-4 text-cyan-300" />
|
||||
<h4 className="text-sm font-semibold">{t("历史快照", "Backup snapshots")}</h4>
|
||||
</div>
|
||||
{backups.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{backups.slice(0, 8).map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-white/8 bg-black/25 px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs text-white">{new Date(item.created_at).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", { hour12: false })}</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500">
|
||||
{item.reason} · {formatBytes(item.size)}
|
||||
{item.source === "legacy" ? ` · ${t("旧备份", "legacy")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || !!restoreBusy}
|
||||
onClick={() => void restoreFromBackup(item.id)}
|
||||
className="btn-focus inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-100 disabled:opacity-40"
|
||||
>
|
||||
{restoreBusy === item.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("恢复", "Restore")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-slate-500">{t("当前文件还没有 Web 侧快照。保存、删除或恢复后会自动累积历史。", "No Web snapshots yet. Saving, deleting, or restoring will accumulate history automatically.")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import OverviewCharts from "@/components/OverviewCharts";
|
||||
import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel";
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
@@ -236,6 +237,7 @@ export function LiveConsoleWorkspace() {
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>("tap");
|
||||
const [previewPoint, setPreviewPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
const [fileImportKey, setFileImportKey] = useState(0);
|
||||
const [configInsightVersion, setConfigInsightVersion] = useState(0);
|
||||
const [shotObjectUrl, setShotObjectUrl] = useState<string | null>(null);
|
||||
const [shotFetchErr, setShotFetchErr] = useState<string | null>(null);
|
||||
const [shotLoading, setShotLoading] = useState(false);
|
||||
@@ -642,6 +644,7 @@ export function LiveConsoleWorkspace() {
|
||||
});
|
||||
notify(tr("文件已保存", "File saved"));
|
||||
await refreshFiles(rootId, filePath);
|
||||
setConfigInsightVersion((value) => value + 1);
|
||||
} catch (reason) {
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
@@ -665,6 +668,7 @@ export function LiveConsoleWorkspace() {
|
||||
});
|
||||
notify(tr("文件已创建", "File created"));
|
||||
await refreshFiles(rootId, nextPath);
|
||||
setConfigInsightVersion((value) => value + 1);
|
||||
} catch (reason) {
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
@@ -687,6 +691,7 @@ export function LiveConsoleWorkspace() {
|
||||
);
|
||||
notify(tr("文件已删除", "File deleted"));
|
||||
await refreshFiles(rootId);
|
||||
setConfigInsightVersion((value) => value + 1);
|
||||
} catch (reason) {
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
@@ -711,6 +716,7 @@ export function LiveConsoleWorkspace() {
|
||||
notify(tr("导入完成,已替换同名文件", "Import finished and replaced matching file"));
|
||||
setFilePath(upload.name);
|
||||
await refreshFiles(rootId, upload.name);
|
||||
setConfigInsightVersion((value) => value + 1);
|
||||
} catch (reason) {
|
||||
notify((reason as Error).message);
|
||||
} finally {
|
||||
@@ -1518,6 +1524,20 @@ export function LiveConsoleWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 px-4 py-3 font-mono text-[11px] text-slate-500">{filePath ? `${rootId} / ${filePath}` : tr("请选择文件", "Pick a file")}</div>
|
||||
<div className="mt-4">
|
||||
<ConfigAdvisorPanel
|
||||
lang={lang}
|
||||
rootId={rootId}
|
||||
filePath={filePath}
|
||||
fileContent={fileContent}
|
||||
refreshKey={configInsightVersion}
|
||||
busy={busy}
|
||||
fetchJson={fetchJson}
|
||||
notify={notify}
|
||||
onChangeContent={setFileContent}
|
||||
onRefreshListing={() => refreshFiles(rootId, filePath)}
|
||||
/>
|
||||
</div>
|
||||
<textarea className="log-scroll mt-4 min-h-[32rem] w-full resize-y rounded-2xl border border-white/10 bg-black/45 p-4 font-mono text-xs leading-relaxed text-slate-200" spellCheck={false} value={fileContent} onChange={(event) => setFileContent(event.target.value)} />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -71,6 +71,7 @@ export function LiveYoutubeUnmannedView({
|
||||
const [proUrlDraft, setProUrlDraft] = useState("");
|
||||
const [urlAutoRefresh, setUrlAutoRefresh] = useState(true);
|
||||
const [urlReloading, setUrlReloading] = useState(false);
|
||||
const [urlOptimizing, setUrlOptimizing] = useState(false);
|
||||
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
|
||||
const urlListDirtyRef = useRef(false);
|
||||
|
||||
@@ -260,6 +261,32 @@ export function LiveYoutubeUnmannedView({
|
||||
await saveYoutubeIniToServer(body);
|
||||
};
|
||||
|
||||
const applyYoutubeBalancedPreset = async () => {
|
||||
setYtIniLoading(true);
|
||||
setYtIniStatus(null);
|
||||
try {
|
||||
const source = youtubeIniText || buildYoutubeIniFromFields(ytFields);
|
||||
const data = await fetchJson<{ message?: string; content?: string }>("/config_optimize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
root: "app-config",
|
||||
path: "youtube.ini",
|
||||
content: source,
|
||||
action: "youtube_balanced_preset",
|
||||
}),
|
||||
});
|
||||
const next = data.content ?? source;
|
||||
setYoutubeIniText(next);
|
||||
setYtFields(parseYoutubeIniFields(next));
|
||||
setYtIniStatus(data.message || t("已应用 YouTube 预设", "Applied YouTube preset"));
|
||||
} catch (e) {
|
||||
setYtIniStatus((e as Error).message);
|
||||
} finally {
|
||||
setYtIniLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncFieldsFromText = () => {
|
||||
setYtFields(parseYoutubeIniFields(youtubeIniText));
|
||||
setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw"));
|
||||
@@ -292,6 +319,36 @@ export function LiveYoutubeUnmannedView({
|
||||
}
|
||||
};
|
||||
|
||||
const dedupeUrlDraft = async () => {
|
||||
const source = mode === "single" ? urlConfig : proUrlDraft;
|
||||
setUrlOptimizing(true);
|
||||
setUrlFetchNote(null);
|
||||
try {
|
||||
const data = await fetchJson<{ message?: string; content?: string }>("/config_optimize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
root: "app-config",
|
||||
path: "URL_config.ini",
|
||||
content: source,
|
||||
action: "dedupe_urls",
|
||||
}),
|
||||
});
|
||||
const next = data.content ?? source;
|
||||
if (mode === "single") setUrlConfig(next);
|
||||
else {
|
||||
setProUrlDraft(next);
|
||||
if (active) updateActive({ urlLines: next });
|
||||
}
|
||||
urlListDirtyRef.current = true;
|
||||
setUrlFetchNote(data.message || t("已去重当前 URL 列表", "Deduplicated current URL list"));
|
||||
} catch (e) {
|
||||
setUrlFetchNote((e as Error).message);
|
||||
} finally {
|
||||
setUrlOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pushProStreamKeyToServer = async () => {
|
||||
if (!active) return;
|
||||
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
|
||||
@@ -306,7 +363,7 @@ export function LiveYoutubeUnmannedView({
|
||||
updateActive({ streamKey: f.key });
|
||||
};
|
||||
|
||||
const lock = busy || ytIniLoading || urlReloading;
|
||||
const lock = busy || ytIniLoading || urlReloading || urlOptimizing;
|
||||
|
||||
const urlToolbar = (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-zinc-400">
|
||||
@@ -327,6 +384,14 @@ export function LiveYoutubeUnmannedView({
|
||||
>
|
||||
{t("手动重新读取", "Reload from server")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void dedupeUrlDraft()}
|
||||
className="rounded-lg border border-cyan-500/20 bg-cyan-500/10 px-3 py-1.5 text-cyan-100 hover:bg-cyan-500/15"
|
||||
>
|
||||
{urlOptimizing ? t("去重中...", "Deduplicating...") : t("智能去重", "Deduplicate")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -564,6 +629,14 @@ export function LiveYoutubeUnmannedView({
|
||||
{ytIniLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t("保存 youtube.ini", "Save youtube.ini")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void applyYoutubeBalancedPreset()}
|
||||
className="rounded-xl border border-cyan-500/20 bg-cyan-500/10 px-4 py-2 text-sm text-cyan-100"
|
||||
>
|
||||
{t("平衡预设", "Balanced preset")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
|
||||
Reference in New Issue
Block a user