This commit is contained in:
QWQLwToo
2026-07-12 09:12:12 +08:00
committed by admin_gitea
parent d44200fde1
commit 5e4355700f
43 changed files with 2797 additions and 709 deletions
+126 -29
View File
@@ -14,6 +14,11 @@ import {
RefreshCw,
ShieldCheck,
} from "lucide-vue-next";
import Button from "primevue/button";
import ConfirmDialog from "primevue/confirmdialog";
import Tag from "primevue/tag";
import Toast from "primevue/toast";
import Toolbar from "primevue/toolbar";
import EndpointsView from "./views/EndpointsView.vue";
import FeedbacksView from "./views/FeedbacksView.vue";
import LegacyJsonView from "./views/LegacyJsonView.vue";
@@ -29,6 +34,7 @@ import { createLegacyStore, type LegacyName } from "./stores/legacy";
import { createReleaseStore } from "./stores/releases";
import { createSourceStore } from "./stores/sources";
import { createSystemStore } from "./stores/system";
import { applyDocumentBranding, normalizeBranding } from "./branding";
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
@@ -127,6 +133,25 @@ const activeMediaCategory = computed(() => {
return categories[activeMediaCategoryIndex.value] || null;
});
const systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
const sourceRows = computed(() => sourceCategories.value.flatMap((cat: any) => (cat.subcategories || []).map((src: any) => ({
...src,
categoryName: cat.name || cat.id || src.categoryName || src.categoryId || "未分类",
status: endpointStatus(src),
latencyMs: sourceLatency(src),
checkedAt: sourceCheckedAt(src),
healthError: sourceHealthError(src),
}))));
const sourceAvailability = computed(() => {
const total = sourceRows.value.length;
const healthy = sourceRows.value.filter((item: any) => ["ok", "redirected"].includes(item.status)).length;
return total ? Math.round((healthy / total) * 100) : 0;
});
const sourceAverageLatency = computed(() => averageLatency(sourceRows.value.map((item: any) => item.latencyMs)));
const sourceMaxLatency = computed(() => {
const values = sourceRows.value.map((item: any) => Number(item.latencyMs)).filter((item: number) => Number.isFinite(item) && item >= 0);
return values.length ? Math.max(...values) : 0;
});
const sourceLastCheckedAt = computed(() => sourceRows.value.map((item: any) => item.checkedAt).filter(Boolean).sort().pop() || "");
const heartbeatChartRows = computed(() => {
const rows = heartbeats.value
.slice()
@@ -142,6 +167,19 @@ const heartbeatChartRows = computed(() => {
return rows;
});
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
const averageLatencyRows = computed(() => {
const rows = dashboard.value?.averageLatency || dashboard.value?.average_latency || [];
if (Array.isArray(rows) && rows.length) {
return rows.map((item: any) => ({
label: item.label || timeLabel(item.checkedAt || item.checked_at),
latency: Number(item.averageLatency ?? item.avgLatencyMs ?? item.average_latency ?? item.latencyMs ?? 0),
sampleCount: Number(item.sampleCount ?? item.sample_count ?? 0),
checkedAt: item.checkedAt || item.checked_at || "",
})).filter((item: any) => Number.isFinite(item.latency));
}
return heartbeatChartRows.value.map((item: any) => ({ ...item, sampleCount: 1 }));
});
const isAverageLatencyChartEmpty = computed(() => averageLatencyRows.value.length === 0);
const heartbeatOption = computed(() => ({
animation: true,
@@ -149,8 +187,8 @@ const heartbeatOption = computed(() => ({
grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true },
xAxis: {
type: "category",
boundaryGap: heartbeatChartRows.value.length <= 1,
data: heartbeatChartRows.value.map((item: any) => item.label),
boundaryGap: averageLatencyRows.value.length <= 1,
data: averageLatencyRows.value.map((item: any) => item.label),
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisLabel: { color: "#64748b" },
},
@@ -171,7 +209,7 @@ const heartbeatOption = computed(() => ({
symbolSize: 7,
connectNulls: true,
areaStyle: { opacity: 0.18 },
data: heartbeatChartRows.value.map((item: any) => item.latency),
data: averageLatencyRows.value.map((item: any) => item.latency),
color: "#2563eb",
lineStyle: { width: 3 },
emphasis: { focus: "series" },
@@ -283,6 +321,7 @@ const viewContext = computed(() => ({
toggleAllFeedbackCodes,
bulkUpdateFeedbacks,
formatBytes,
formatDateTime,
formatHealthOutput,
healthOption: healthOption.value,
healthSnapshot: healthSnapshot.value,
@@ -290,6 +329,7 @@ const viewContext = computed(() => ({
heartbeatOption: heartbeatOption.value,
heartbeats: heartbeats.value,
isHeartbeatChartEmpty: isHeartbeatChartEmpty.value,
isAverageLatencyChartEmpty: isAverageLatencyChartEmpty.value,
importNotices,
kpis: kpis.value,
labelStatus,
@@ -343,7 +383,15 @@ const viewContext = computed(() => ({
selectedNotice: selectedNotice.value,
sourceCategories: sourceCategories.value,
sourceCheckJobs: sourceCheckJobs.value,
sourceRows: sourceRows.value,
sourceAvailability: sourceAvailability.value,
sourceAverageLatency: sourceAverageLatency.value,
sourceMaxLatency: sourceMaxLatency.value,
sourceLastCheckedAt: sourceLastCheckedAt.value,
sourceDraft,
sourceLatency,
sourceCheckedAt,
sourceHealthError,
statusTone,
syncDatabase,
systemLogPage,
@@ -483,7 +531,12 @@ async function load() {
navigate("/admin/login");
return;
}
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/dashboard") await Promise.all([
loadDashboard(),
loadSources().catch(() => undefined),
loadEndpoints().catch(() => undefined),
loadSourceCheckJobs().catch(() => undefined),
]);
if (currentPath.value === "/admin/feedbacks") await loadFeedbacks();
if (currentPath.value === "/admin/releases") await loadReleases();
if (currentPath.value === "/admin/sources") await loadSources();
@@ -1183,12 +1236,8 @@ async function loadMigrationStatus() {
async function loadBranding() {
const data = await api<{ branding: any }>("/api/admin/system/branding");
Object.assign(branding, {
siteIconUrl: data.branding?.siteIconUrl || branding.siteIconUrl,
developerAvatarUrl: data.branding?.developerAvatarUrl || branding.developerAvatarUrl,
developerName: data.branding?.developerName || "YMhut",
feedbackEmail: data.branding?.feedbackEmail || "support@ymhut.cn",
});
Object.assign(branding, normalizeBranding(data.branding || branding));
applyDocumentBranding(branding, "admin");
}
async function saveBranding() {
@@ -1196,13 +1245,20 @@ async function saveBranding() {
const data = await api<{ branding: any }>("/api/admin/system/branding", {
method: "POST",
body: JSON.stringify({
siteName: branding.siteName,
portalTitle: branding.portalTitle,
portalSubtitle: branding.portalSubtitle,
adminTitle: branding.adminTitle,
adminSubtitle: branding.adminSubtitle,
siteIconUrl: branding.siteIconUrl,
logoUrl: branding.logoUrl,
developerAvatarUrl: branding.developerAvatarUrl,
developerName: branding.developerName,
feedbackEmail: branding.feedbackEmail,
}),
});
Object.assign(branding, data.branding || {});
Object.assign(branding, normalizeBranding(data.branding || branding));
applyDocumentBranding(branding, "admin");
if (!mailConfig.developerAddress) mailConfig.developerAddress = branding.feedbackEmail;
setToast("站点品牌信息已保存");
});
@@ -1367,6 +1423,38 @@ function endpointStatus(item: any) {
return item.health?.status || item.lastStatus || "unknown";
}
function sourceLatency(item: any) {
return firstFiniteNumber(item.health?.latencyMs, item.health?.latency_ms, item.lastLatencyMs, item.last_latency_ms, item.latencyMs, item.latency_ms) ?? 0;
}
function sourceCheckedAt(item: any) {
return item.health?.lastCheckedAt || item.health?.last_checked_at || item.lastCheckedAt || item.last_checked_at || item.checkedAt || item.checked_at || "";
}
function sourceHealthError(item: any) {
return item.health?.lastError || item.health?.last_error || item.lastError || item.last_error || item.error || "";
}
function averageLatency(values: unknown[]) {
const numeric = values.map((item) => Number(item)).filter((item) => Number.isFinite(item) && item >= 0);
return numeric.length ? Math.round(numeric.reduce((sum, item) => sum + item, 0) / numeric.length) : 0;
}
function firstFiniteNumber(...values: unknown[]) {
for (const value of values) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 0) return numeric;
}
return null;
}
function formatDateTime(value: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
function statusTone(status: string) {
const value = String(status || "").toLowerCase();
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready", "completed"].includes(value)) return "good";
@@ -1540,8 +1628,10 @@ onMounted(() => {
localStorage.removeItem("ymhut.csrf");
void load();
refreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
}, 15000);
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
if (!autoRefreshPaused.value && currentPath.value === "/admin/sources" && csrf.value) void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
if (!autoRefreshPaused.value && currentPath.value === "/admin/endpoints" && csrf.value) void loadEndpoints();
}, 20000);
systemRefreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/system" && csrf.value) void loadSystem({ preserveForms: true });
}, 60000);
@@ -1580,6 +1670,9 @@ function connectAdminEvents() {
</script>
<template>
<Toast />
<ConfirmDialog />
<Teleport to="body">
<div v-if="toast" :class="['toast', toast.type]">{{ toast.message }}</div>
</Teleport>
@@ -1587,9 +1680,9 @@ function connectAdminEvents() {
<main v-if="currentPath === '/admin/login'" class="login-shell">
<section class="login-panel">
<div>
<p class="eyebrow">YMhut Unified Management</p>
<p class="eyebrow">{{ branding.siteName }}</p>
<h1>后台登录</h1>
<p class="muted">验证码和密码都由服务端校验登录后写操作继续要求 CSRF Token</p>
<p class="muted">{{ branding.adminSubtitle }}验证码和密码都由服务端校验登录后写操作继续要求 CSRF Token</p>
</div>
<p v-if="authBootstrap?.isDefaultPassword" class="alert-line">
当前使用默认账号{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
@@ -1607,7 +1700,7 @@ function connectAdminEvents() {
</button>
</div>
</label>
<button class="btn primary full" type="submit">登录</button>
<Button class="full" type="submit" label="登录" />
</form>
</section>
</main>
@@ -1616,10 +1709,10 @@ function connectAdminEvents() {
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">
<img v-if="branding.siteIconUrl" :src="branding.siteIconUrl" alt="YMhut" />
<img v-if="branding.logoUrl || branding.siteIconUrl" :src="branding.logoUrl || branding.siteIconUrl" :alt="branding.siteName" />
<ShieldCheck v-else :size="22" />
</span>
<div><strong>{{ branding.developerName || "YMhut" }}</strong><small>统一管理台</small></div>
<div><strong>{{ branding.adminTitle || "统一管理台" }}</strong><small>{{ branding.siteName }}</small></div>
</div>
<nav class="nav-groups">
<section v-for="group in navGroups" :key="group.label" class="nav-group">
@@ -1639,17 +1732,21 @@ function connectAdminEvents() {
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">update.ymhut.cn</p>
<h1>{{ pageMeta.label }}</h1>
<p class="muted">{{ pageMeta.description }}</p>
</div>
<div class="top-actions">
<span v-if="loading" class="badge warn">加载中</span>
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
</div>
</header>
<Toolbar class="topbar">
<template #start>
<div>
<p class="eyebrow">{{ branding.adminSubtitle }}</p>
<h1>{{ pageMeta.label }}</h1>
<p class="muted">{{ pageMeta.description }}</p>
</div>
</template>
<template #end>
<div class="top-actions">
<Tag v-if="loading" severity="warn" value="加载中" />
<Button severity="secondary" outlined rounded @click="load"><RefreshCw :size="16" />刷新</Button>
</div>
</template>
</Toolbar>
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />