继续更新 update 门户站点界面和功能

This commit is contained in:
QWQLwToo
2026-06-26 14:33:43 +08:00
parent cd2fd435a2
commit 2171b933eb
5 changed files with 286 additions and 19 deletions
@@ -59,10 +59,12 @@ const toast = ref<ToastState | null>(null);
const autoRefreshPaused = ref(false);
let refreshTimer: number | undefined;
let toastTimer: number | undefined;
let events: EventSource | null = null;
const captcha = ref<Captcha | null>(null);
const authBootstrap = ref<AuthBootstrap | null>(null);
const dashboard = ref<any>({});
const sourceCheckJobs = ref<any[]>([]);
const feedbackPage = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
const selectedFeedback = ref<any | null>(null);
const releases = ref<any>(null);
@@ -303,6 +305,7 @@ const viewContext = computed(() => ({
selectedFeedback: selectedFeedback.value,
selectedNotice: selectedNotice.value,
sourceCategories: sourceCategories.value,
sourceCheckJobs: sourceCheckJobs.value,
sourceDraft,
statusTone,
syncDatabase,
@@ -385,6 +388,7 @@ async function login() {
});
csrf.value = data.csrfToken;
localStorage.setItem("ymhut.csrf", csrf.value);
connectAdminEvents();
navigate("/admin/dashboard");
});
}
@@ -393,6 +397,8 @@ async function logout() {
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
csrf.value = "";
localStorage.removeItem("ymhut.csrf");
events?.close();
events = null;
navigate("/admin/login");
}
@@ -716,13 +722,19 @@ async function saveSource() {
async function checkSources() {
await guarded(async () => {
await api("/api/admin/sources/check", { method: "POST", body: "{}" });
setToast("接口心跳检测已进入队列");
const data = await api<{ jobId: string; job: any }>("/api/admin/sources/check", { method: "POST", body: "{}" });
if (data.job) sourceCheckJobs.value = [data.job, ...sourceCheckJobs.value.filter((item) => item.id !== data.job.id)].slice(0, 5);
setToast(`接口心跳检测已进入队列:${data.jobId}`);
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/sources") await loadSources();
});
}
async function loadSourceCheckJobs() {
const data = await api<{ items: any[] }>("/api/admin/sources/check/status");
sourceCheckJobs.value = data.items || [];
}
async function loadEndpoints() {
const data = await api<{ items: any[] }>("/api/admin/endpoints");
endpoints.value = data.items || [];
@@ -795,10 +807,11 @@ async function loadAudit() {
async function changePassword() {
await guarded(async () => {
await api("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
const data = await api<{ isDefaultPassword: boolean; warning?: string }>("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
passwordForm.currentPassword = "";
passwordForm.newPassword = "";
setToast("后台密码已修改,登录页将不再提示默认密码");
if (authBootstrap.value) authBootstrap.value.isDefaultPassword = data.isDefaultPassword;
setToast(data.warning || "后台密码已修改,登录页将不再提示默认密码", data.warning ? "warn" : "success");
});
}
@@ -909,6 +922,7 @@ function splitList(value: string) {
onMounted(() => {
void load();
connectAdminEvents();
refreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
}, 15000);
@@ -916,7 +930,30 @@ onMounted(() => {
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer);
events?.close();
events = null;
});
function connectAdminEvents() {
if (!csrf.value || events) return;
events = new EventSource("/api/admin/events", { withCredentials: true });
const refreshCurrent = () => {
if (autoRefreshPaused.value) return;
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
if (currentPath.value === "/admin/audit") void loadAudit();
if (currentPath.value === "/admin/database") void loadDatabase();
};
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
events.addEventListener(name, refreshCurrent);
}
events.onerror = () => {
events?.close();
events = null;
window.setTimeout(connectAdminEvents, 5000);
};
}
</script>
<template>
@@ -24,8 +24,8 @@
}
* { box-sizing: border-box; }
html { min-width: 320px; }
body { margin: 0; background: var(--bg); }
html { min-width: 320px; overflow-x: hidden; }
body { margin: 0; background: var(--bg); overflow-x: hidden; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.65; }
@@ -154,7 +154,7 @@ input:focus, textarea:focus, select:focus {
to { opacity: 1; transform: translate(-50%, 0); }
}
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
.app-shell { min-height: 100dvh; max-width: 100vw; overflow-x: hidden; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
.sidebar {
border-right: 1px solid var(--line);
background: rgba(255, 255, 255, 0.94);
@@ -171,7 +171,7 @@ input:focus, textarea:focus, select:focus {
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
.brand strong { display: block; }
.brand small { display: block; color: var(--muted); margin-top: 2px; }
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; overflow-x: hidden; }
.nav-group { display: flex; flex-direction: column; gap: 5px; }
.nav-group p {
margin: 0 0 2px;
@@ -194,11 +194,18 @@ input:focus, textarea:focus, select:focus {
font-weight: 800;
transition: background-color 0.18s ease, color 0.18s ease;
}
.nav-group button svg, .logout svg { flex: 0 0 auto; }
.nav-group button span, .logout span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-group button:hover, .logout:hover { transform: translateX(2px); background: #eef4ff; color: var(--primary-dark); }
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
.logout { color: #7f1d1d; }
.workspace { min-width: 0; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
.workspace { min-width: 0; max-width: 100%; overflow-x: hidden; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
.topbar, .section-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
.topbar { min-height: 72px; }
.section-head h2 { margin: 0; }
@@ -399,7 +406,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
.quick-grid { grid-template-columns: 1fr; }
.captcha-row { grid-template-columns: 1fr; }
table { min-width: 720px; }
.panel { overflow-x: auto; }
.panel { overflow-x: auto; max-width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
@@ -29,6 +29,24 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
<span class="muted"> 15 秒自动刷新仪表盘数据</span>
</div>
<section v-if="ctx.sourceCheckJobs.length" class="panel">
<div class="section-head"><h2>心跳检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
<table>
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
<tbody>
<tr v-for="job in ctx.sourceCheckJobs.slice(0, 5)" :key="job.id">
<td class="mono">{{ job.id }}</td>
<td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
<td>{{ job.stats?.ok || 0 }}</td>
<td>{{ job.stats?.redirected || 0 }}</td>
<td>{{ job.stats?.degraded || 0 }}</td>
<td>{{ job.stats?.error || 0 }}</td>
<td>{{ job.startedAt || "-" }}</td>
</tr>
</tbody>
</table>
</section>
<section class="panel quick-panel">
<div class="section-head"><h2>功能总览</h2><span class="badge">{{ ctx.quickActions.length }} 个入口</span></div>
<div class="quick-grid">