feat(router): v0.3.0 阶段二 管理入口迁移与前端同步
管理入口从 /admin 迁至 /dashboard: - /dashboard、/dashboard/voices、/dashboard/settings 为新入口 - 原挂在 /dashboard 上的服务状态预览页(health.html)迁至 /dashboard/status, 侧边栏新增「观测 → 详细状态」入口 - /admin 及子路径 301 永久重定向到对应新路径(页面用 301 是正确的) - 静态资源同时注册 /dashboard/ 与 /admin/ 两套路径, 避免过渡期出现"页面能开但丢样式" 前端同步: - admin-shell.js:侧边栏导航、登出、登录跳转改指 /dashboard/* - admin.html / admin-login.html / admin-voices.html / admin-settings.html: 静态资源引用与登录跳转改指 /dashboard/* - health.html:改用 admin-shell 的 http 实例并改调 /admin/health、/admin/metrics (该实例自动附加 Authorization: Bearer;原实现用裸 axios,不带凭证), 并加登录校验与「返回控制台」入口 - setup.html:安装完成跳转目标改为 /dashboard,页面内 /admin 文案同步更新 - 各页面的 API 调用路径无需修改:axios baseURL 为 /api, 故 /voices、/admin/overview 等自动落成 /api/admin/* controller/setup.go:安装成功响应的 redirect 字段由 /admin 改为 /dashboard。 验证:go build / go vet / go test ./... -count=1 全绿; 另用真实服务器端到端验证:旧入口 301 全部正确、静态资源新旧路径均 200、 管理页面内容协商(html 200 / 非 html 401)、登录页公开、 各页面引用的静态资源均为新路径且无残留旧路径。
This commit is contained in:
+1
-1
@@ -244,7 +244,7 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"message": "installed",
|
"message": "installed",
|
||||||
"redirect": "/admin",
|
"redirect": "/dashboard", // v0.3.0: 管理入口由 /admin 迁至 /dashboard
|
||||||
"settings": len(settingsKV),
|
"settings": len(settingsKV),
|
||||||
"voices": inserted,
|
"voices": inserted,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>管理后台 · 登录</title>
|
<title>管理后台 · 登录</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref } = Vue;
|
const { createApp, ref } = Vue;
|
||||||
const { http, setKey, clearKey, getKey } = window.admin;
|
const { http, setKey, clearKey, getKey } = window.admin;
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
// 已登录则直接跳 dashboard
|
// 已登录则直接跳 dashboard
|
||||||
if (getKey()) {
|
if (getKey()) {
|
||||||
http.get('/admin/overview').then(() => {
|
http.get('/admin/overview').then(() => {
|
||||||
location.href = '/admin';
|
location.href = '/dashboard';
|
||||||
}).catch(() => { clearKey(); });
|
}).catch(() => { clearKey(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
loginErr.value = '';
|
loginErr.value = '';
|
||||||
try {
|
try {
|
||||||
await window.admin.login(keyInput.value);
|
await window.admin.login(keyInput.value);
|
||||||
location.href = '/admin';
|
location.href = '/dashboard';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
|
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>火山 TTS · 系统设置</title>
|
<title>火山 TTS · 系统设置</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">鉴权 Key</div>
|
<div class="card-title">鉴权 Key</div>
|
||||||
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code> 与登录 /admin 共用</div>
|
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code>。管理后台登录改用 <code>admin_key</code>(未配置时回退用本 Key)</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="pill pill-accent">敏感</span>
|
<span class="pill pill-accent">敏感</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, computed, onMounted } = Vue;
|
const { createApp, ref, computed, onMounted } = Vue;
|
||||||
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
|
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
authKeyInput.value = '';
|
authKeyInput.value = '';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearKey();
|
clearKey();
|
||||||
location.href = '/admin/login';
|
location.href = '/dashboard/login';
|
||||||
}, 200);
|
}, 200);
|
||||||
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
|
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
const resetSettingsForm = () => { loadSettings(); };
|
const resetSettingsForm = () => { loadSettings(); };
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
mountShell({
|
mountShell({
|
||||||
activeNav: 'settings',
|
activeNav: 'settings',
|
||||||
overview: overview.value,
|
overview: overview.value,
|
||||||
|
|||||||
+10
-4
@@ -112,21 +112,27 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">概览</div>
|
<div class="nav-label">概览</div>
|
||||||
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/admin">
|
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/dashboard">
|
||||||
<span class="nav-icon">▦</span><span>仪表盘</span>
|
<span class="nav-icon">▦</span><span>仪表盘</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">管理</div>
|
<div class="nav-label">管理</div>
|
||||||
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/admin/voices">
|
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/dashboard/voices">
|
||||||
<span class="nav-icon">♪</span><span>音色</span>
|
<span class="nav-icon">♪</span><span>音色</span>
|
||||||
<span class="nav-badge">${voiceCount}</span>
|
<span class="nav-badge">${voiceCount}</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/admin/settings">
|
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/dashboard/settings">
|
||||||
<span class="nav-icon">⚙</span><span>设置</span>
|
<span class="nav-icon">⚙</span><span>设置</span>
|
||||||
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
|
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-label">观测</div>
|
||||||
|
<a class="nav-item${activeNav === 'status' ? ' active' : ''}" href="/dashboard/status">
|
||||||
|
<span class="nav-icon">♥</span><span>详细状态</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-foot">
|
<div class="sidebar-foot">
|
||||||
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
|
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
|
||||||
@@ -142,7 +148,7 @@
|
|||||||
if (btnLogout) btnLogout.addEventListener('click', () => {
|
if (btnLogout) btnLogout.addEventListener('click', () => {
|
||||||
clearKey();
|
clearKey();
|
||||||
if (typeof options.onLogout === 'function') options.onLogout();
|
if (typeof options.onLogout === 'function') options.onLogout();
|
||||||
else location.href = '/admin/login';
|
else location.href = '/dashboard/login';
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>火山 TTS · 音色管理</title>
|
<title>火山 TTS · 音色管理</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
</teleport>
|
</teleport>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, computed, onMounted } = Vue;
|
const { createApp, ref, computed, onMounted } = Vue;
|
||||||
const { http, getKey, toast, mountShell } = window.admin;
|
const { http, getKey, toast, mountShell } = window.admin;
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
mountShell({
|
mountShell({
|
||||||
activeNav: 'voices',
|
activeNav: 'voices',
|
||||||
overview: overview.value,
|
overview: overview.value,
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@
|
|||||||
<title>火山 TTS · 仪表盘</title>
|
<title>火山 TTS · 仪表盘</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/admin/admin-shell.js"></script>
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, onMounted } = Vue;
|
const { createApp, ref, onMounted } = Vue;
|
||||||
const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin;
|
const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin;
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!getKey()) { location.href = '/admin/login'; return; }
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview });
|
mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview });
|
||||||
loadOverview();
|
loadOverview();
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-3
@@ -6,6 +6,7 @@
|
|||||||
<title>TTS 服务监控</title>
|
<title>TTS 服务监控</title>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
|
||||||
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/dashboard/admin.css">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0f1a;
|
--bg: #0b0f1a;
|
||||||
@@ -148,6 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-flex">
|
<div class="row-flex">
|
||||||
|
<a class="btn" href="/dashboard" style="text-decoration:none;">← 返回控制台</a>
|
||||||
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
|
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
|
||||||
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
|
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
|
||||||
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
|
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
|
||||||
@@ -377,8 +379,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/dashboard/admin-shell.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const { createApp, ref, computed, onMounted } = Vue;
|
const { createApp, ref, computed, onMounted } = Vue;
|
||||||
|
// v0.3.0:/health 与 /metrics 已收口到鉴权接口,统一走带凭证头的 /api/admin/*
|
||||||
|
const { http, getKey } = window.admin;
|
||||||
|
|
||||||
function parseProm(text) {
|
function parseProm(text) {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -597,16 +602,16 @@
|
|||||||
|
|
||||||
const fetchHealth = async () => {
|
const fetchHealth = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await axios.get("/health");
|
const r = await http.get("/admin/health");
|
||||||
health.value = r.data;
|
health.value = r.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = "无法获取健康状态: " + (e.message || "未知错误");
|
error.value = "无法获取健康状态: " + (e.response?.data?.error?.message || e.message || "未知错误");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await axios.get("/metrics");
|
const r = await http.get("/admin/metrics");
|
||||||
metrics.value = parseProm(r.data);
|
metrics.value = parseProm(r.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 不覆盖 error,让 health 错误更显眼
|
// 不覆盖 error,让 health 错误更显眼
|
||||||
@@ -650,6 +655,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// v0.3.0:本页已收口到管理鉴权,未登录先回登录页
|
||||||
|
if (!getKey()) { location.href = '/dashboard/login'; return; }
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 10000);
|
setInterval(refresh, 10000);
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -202,7 +202,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>OpenAI 鉴权 Key <span class="req">*</span></label>
|
<label>OpenAI 鉴权 Key <span class="req">*</span></label>
|
||||||
<input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off">
|
<input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off">
|
||||||
<div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/admin</code> 的密码。装完可在 <code>/admin → 设置</code> 改。</div>
|
<div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/dashboard</code> 的密码。装完可在 <code>/dashboard → 设置</code> 改(可另配独立 <code>admin_key</code> 用于后台登录,避免与业务 Key 共用)。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>火山 TTS API Key <span class="req">*</span></label>
|
<label>火山 TTS API Key <span class="req">*</span></label>
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">默认路由与格式</div>
|
<div class="card-title">默认路由与格式</div>
|
||||||
<div class="card-sub">未传 voice/format 时使用。装完可在 <code>/admin → 设置</code> 改。</div>
|
<div class="card-sub">未传 voice/format 时使用。装完可在 <code>/dashboard → 设置</code> 改。</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="step-badge">第 2 步</span>
|
<span class="step-badge">第 2 步</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
|
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
|
||||||
<div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。</div>
|
<div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /dashboard 中增删。</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="step-badge">第 3 步</span>
|
<span class="step-badge">第 3 步</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
<!-- Nav -->
|
<!-- Nav -->
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button>
|
<button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button>
|
||||||
<div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。</div>
|
<div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/dashboard</code>。</div>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button>
|
<button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button>
|
||||||
<button v-else class="btn btn-primary" @click="submit" :disabled="submitting">
|
<button v-else class="btn btn-primary" @click="submit" :disabled="submitting">
|
||||||
@@ -412,7 +412,7 @@
|
|||||||
},
|
},
|
||||||
voices: form.value.voices,
|
voices: form.value.voices,
|
||||||
});
|
});
|
||||||
window.location.href = r.data.redirect || '/admin';
|
window.location.href = r.data.redirect || '/dashboard';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e.response?.data?.error?.message || e.message;
|
const msg = e.response?.data?.error?.message || e.message;
|
||||||
errorMsg.value = '安装失败: ' + msg;
|
errorMsg.value = '安装失败: ' + msg;
|
||||||
|
|||||||
Reference in New Issue
Block a user