更新UI
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user