更新UI
This commit is contained in:
@@ -8,9 +8,12 @@
|
||||
"name": "ymhut-unified-admin",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"echarts": "^6.1.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-echarts": "^8.0.1",
|
||||
@@ -488,6 +491,74 @@
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@primeuix/styled": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/styled/-/styled-0.7.4.tgz",
|
||||
"integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/styles": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/styles/-/styles-2.0.3.tgz",
|
||||
"integrity": "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/themes": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/themes/-/themes-1.2.5.tgz",
|
||||
"integrity": "sha512-n3YkwJrHQaEESc/D/A/iD815sxp8cKnmzscA6a8Tm8YvMtYU32eCahwLLe6h5rywghVwxASWuG36XBgISYOIjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/utils": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/utils/-/utils-0.6.4.tgz",
|
||||
"integrity": "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/core": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@primevue/core/-/core-4.5.5.tgz",
|
||||
"integrity": "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4",
|
||||
"@primeuix/utils": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/icons": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@primevue/icons/-/icons-4.5.5.tgz",
|
||||
"integrity": "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.6.2",
|
||||
"@primevue/core": "4.5.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||
@@ -1165,6 +1236,28 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/primeicons": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/primeicons/-/primeicons-7.0.0.tgz",
|
||||
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/primevue": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/primevue/-/primevue-4.5.5.tgz",
|
||||
"integrity": "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4",
|
||||
"@primeuix/styles": "^2.0.3",
|
||||
"@primeuix/utils": "^0.6.2",
|
||||
"@primevue/core": "4.5.5",
|
||||
"@primevue/icons": "4.5.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"echarts": "^6.1.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-echarts": "^8.0.1",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type Branding = {
|
||||
siteName: string;
|
||||
portalTitle: string;
|
||||
portalSubtitle: string;
|
||||
adminTitle: string;
|
||||
adminSubtitle: string;
|
||||
siteIconUrl: string;
|
||||
logoUrl: string;
|
||||
developerAvatarUrl: string;
|
||||
developerName: string;
|
||||
feedbackEmail: string;
|
||||
};
|
||||
|
||||
export const defaultBranding: Branding = {
|
||||
siteName: "YMhut Box",
|
||||
portalTitle: "YMhut Box 统一服务门户",
|
||||
portalSubtitle: "统一发布、反馈与接口源状态门户",
|
||||
adminTitle: "YMhut 统一管理台",
|
||||
adminSubtitle: "发布、反馈、接口源与系统运维",
|
||||
siteIconUrl: "/assets/favicon.ico",
|
||||
logoUrl: "/assets/favicon.ico",
|
||||
developerAvatarUrl: "/assets/developer-avatar.png",
|
||||
developerName: "YMhut",
|
||||
feedbackEmail: "support@ymhut.cn",
|
||||
};
|
||||
|
||||
function valueOf(source: any, key: keyof Branding) {
|
||||
const value = source?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeBranding(source: any): Branding {
|
||||
const siteName = valueOf(source, "siteName") || defaultBranding.siteName;
|
||||
const siteIconUrl = valueOf(source, "siteIconUrl") || defaultBranding.siteIconUrl;
|
||||
return {
|
||||
siteName,
|
||||
portalTitle: valueOf(source, "portalTitle") || `${siteName} 统一服务门户`,
|
||||
portalSubtitle: valueOf(source, "portalSubtitle") || defaultBranding.portalSubtitle,
|
||||
adminTitle: valueOf(source, "adminTitle") || defaultBranding.adminTitle,
|
||||
adminSubtitle: valueOf(source, "adminSubtitle") || defaultBranding.adminSubtitle,
|
||||
siteIconUrl,
|
||||
logoUrl: valueOf(source, "logoUrl") || siteIconUrl,
|
||||
developerAvatarUrl: valueOf(source, "developerAvatarUrl") || defaultBranding.developerAvatarUrl,
|
||||
developerName: valueOf(source, "developerName") || defaultBranding.developerName,
|
||||
feedbackEmail: valueOf(source, "feedbackEmail") || defaultBranding.feedbackEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDocumentBranding(branding: Branding, surface: "portal" | "admin" = "admin") {
|
||||
if (typeof document === "undefined") return;
|
||||
document.title = surface === "admin" ? branding.adminTitle : branding.portalTitle;
|
||||
let icon = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
|
||||
if (!icon) {
|
||||
icon = document.createElement("link");
|
||||
icon.rel = "icon";
|
||||
document.head.appendChild(icon);
|
||||
}
|
||||
icon.href = branding.siteIconUrl;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import PrimeVue from "primevue/config";
|
||||
import Aura from "@primeuix/themes/aura";
|
||||
import ToastService from "primevue/toastservice";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
import "primeicons/primeicons.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -30,4 +35,25 @@ const router = createRouter({
|
||||
],
|
||||
});
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
ripple: true,
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: ".admin-dark",
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
accept: "确定",
|
||||
reject: "取消",
|
||||
clear: "清除",
|
||||
apply: "应用",
|
||||
emptyMessage: "暂无数据",
|
||||
emptyFilterMessage: "没有匹配结果",
|
||||
},
|
||||
})
|
||||
.use(ToastService)
|
||||
.use(ConfirmationService)
|
||||
.mount("#app");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import { defaultBranding } from "../branding";
|
||||
|
||||
export function createSystemStore() {
|
||||
const database = ref<any>(null);
|
||||
@@ -29,10 +30,7 @@ export function createSystemStore() {
|
||||
});
|
||||
const migrationStatus = ref<any>(null);
|
||||
const branding = reactive({
|
||||
siteIconUrl: "/assets/favicon.ico",
|
||||
developerAvatarUrl: "/assets/developer-avatar.png",
|
||||
developerName: "YMhut",
|
||||
feedbackEmail: "support@ymhut.cn",
|
||||
...defaultBranding,
|
||||
});
|
||||
const databaseForm = reactive({
|
||||
provider: "sqlite",
|
||||
|
||||
@@ -173,8 +173,20 @@ input:focus, textarea:focus, select:focus {
|
||||
overflow: hidden auto;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--primary-dark);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
}
|
||||
.brand-mark img { width: 100%; height: 100%; object-fit: contain; border-radius: 8px; display: block; }
|
||||
.brand strong { display: block; }
|
||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.brand > div { min-width: 0; overflow: hidden; }
|
||||
@@ -268,6 +280,33 @@ input:focus, textarea:focus, select:focus {
|
||||
pointer-events: none;
|
||||
}
|
||||
.chart-empty strong { color: var(--ink); }
|
||||
.health-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.health-kpis.compact { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.health-kpis article {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 12px;
|
||||
}
|
||||
.health-kpis svg { color: var(--primary); }
|
||||
.health-kpis span, .block {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.health-kpis strong {
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.latency-value { color: var(--good); white-space: nowrap; }
|
||||
.latency-value.slow { color: var(--warn); }
|
||||
.block { display: block; margin-top: 4px; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
|
||||
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
|
||||
.legacy-media-editor { grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.05fr); }
|
||||
@@ -511,8 +550,10 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
@@ -626,7 +667,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid, .split, .split.wide-split, .sync-summary { grid-template-columns: 1fr; }
|
||||
.chart-grid, .split, .split.wide-split, .sync-summary, .health-kpis, .health-kpis.compact { grid-template-columns: 1fr; }
|
||||
.detail-panel { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { BarChart, GaugeChart, LineChart, PieChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import { Activity, PauseCircle, PlayCircle } from "lucide-vue-next";
|
||||
import { Activity, Gauge, PauseCircle, PlayCircle, TimerReset } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
@@ -15,8 +15,8 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<section class="page-stack">
|
||||
<div class="metric-grid">
|
||||
<article class="metric"><span>反馈总数</span><strong>{{ ctx.kpis.feedbackTotal || 0 }}</strong><small>今日新增 {{ ctx.kpis.feedbackToday || 0 }}</small></article>
|
||||
<article class="metric"><span>可见接口</span><strong>{{ ctx.kpis.sourceVisible || 0 }}</strong><small>接口总数 {{ ctx.kpis.sourceTotal || 0 }}</small></article>
|
||||
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice && ctx.latestNotice.version ? ctx.latestNotice.version : "暂无最新版本" }}</small></article>
|
||||
<article class="metric"><span>接口可用率</span><strong>{{ ctx.sourceAvailability || 0 }}%</strong><small>{{ ctx.sourceRows.length }} 个接口 · {{ ctx.sourceAverageLatency || 0 }}ms 平均延迟</small></article>
|
||||
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice?.version || "暂无最新版本" }}</small></article>
|
||||
<article class="metric"><span>邮件失败</span><strong>{{ ctx.kpis.mailFailed || 0 }}</strong><small>旧反馈兼容记录</small></article>
|
||||
</div>
|
||||
|
||||
@@ -26,35 +26,43 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
</button>
|
||||
<span class="muted">每 15 秒自动刷新。</span>
|
||||
<span v-if="ctx.lastRefreshedAt" class="muted">上次刷新:{{ ctx.lastRefreshedAt }}</span>
|
||||
<span class="muted">每 20 秒自动刷新接口状态。</span>
|
||||
<span v-if="ctx.sourceLastCheckedAt" class="muted">最近检测:{{ ctx.formatDateTime(ctx.sourceLastCheckedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="health-kpis">
|
||||
<article><Gauge :size="18" /><span>平均延迟</span><strong>{{ ctx.sourceAverageLatency || 0 }}ms</strong></article>
|
||||
<article><TimerReset :size="18" /><span>最大延迟</span><strong>{{ ctx.sourceMaxLatency || 0 }}ms</strong></article>
|
||||
<article><Activity :size="18" /><span>健康接口</span><strong>{{ ctx.healthyEndpointCount || 0 }}/{{ ctx.visibleEndpointCount || 0 }}</strong></article>
|
||||
</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 && job.stats.ok) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.redirected) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.degraded) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.error) || 0 }}</td>
|
||||
<td>{{ job.startedAt || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<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 && job.stats.ok) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.redirected) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.degraded) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.error) || 0 }}</td>
|
||||
<td>{{ ctx.formatDateTime(job.startedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-panel chart-panel-relative">
|
||||
<h2>服务端接口延迟</h2>
|
||||
<h2>所有接口平均延迟</h2>
|
||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
||||
<strong>暂无服务端检测记录</strong>
|
||||
<span>点击立即服务端检测后会生成延迟曲线。</span>
|
||||
<div v-if="ctx.isAverageLatencyChartEmpty" class="chart-empty">
|
||||
<strong>暂无平均延迟记录</strong>
|
||||
<span>服务端 5 秒检测产生记录后会自动绘制趋势。</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
@@ -63,44 +71,49 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>输出</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
||||
<td>{{ item.name || item.sourceId }}</td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ ctx.formatHealthOutput(item) }}</td>
|
||||
<td>{{ item.checkedAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录,点击立即服务端检测后会刷新。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-head"><h2>每接口实时延迟</h2><span class="badge">{{ ctx.sourceRows.length }} 个接口</span></div>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>分类</th><th>状态</th><th>延迟</th><th>最近检测</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.sourceRows.slice(0, 14)" :key="item.id || item.sourceId">
|
||||
<td>{{ item.name || item.sourceId }}</td>
|
||||
<td>{{ item.categoryName || item.categoryId || "-" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td><strong :class="['latency-value', Number(item.latencyMs) >= 1500 ? 'slow' : '']">{{ item.latencyMs || 0 }}ms</strong></td>
|
||||
<td>{{ ctx.formatDateTime(item.checkedAt) }}</td>
|
||||
<td class="hash">{{ item.healthError || "检测正常" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.sourceRows.length === 0"><td colspan="6">暂无接口检测数据。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>客户端调用上报</h2><span class="badge">{{ ctx.clientCalls.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>客户端</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||
<td>{{ item.sourceId }}</td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ item.client || "-" }}</td>
|
||||
<td>{{ item.createdAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.clientCalls.length === 0"><td colspan="5">暂无客户端调用上报。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>客户端</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||
<td>{{ item.sourceId }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ item.client || "-" }}</td>
|
||||
<td>{{ ctx.formatDateTime(item.createdAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.clientCalls.length === 0"><td colspan="5">暂无客户端调用上报。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>系统日志</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select v-model="ctx.systemLogPage.category" style="width:100px" @change="ctx.loadSystemLogs">
|
||||
<div class="button-row">
|
||||
<select v-model="ctx.systemLogPage.category" style="width:120px" @change="ctx.loadSystemLogs">
|
||||
<option value="">全部分类</option>
|
||||
<option value="feedback">反馈</option>
|
||||
<option value="release">发布</option>
|
||||
@@ -110,22 +123,24 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<option value="database">数据库</option>
|
||||
<option value="risk">风险</option>
|
||||
</select>
|
||||
<button class="btn ghost" style="padding:3px 10px;font-size:12px" @click="ctx.loadSystemLogs">刷新</button>
|
||||
<button class="btn ghost compact" @click="ctx.loadSystemLogs">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>分类</th><th>类型</th><th>状态</th><th>消息</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in (ctx.systemLogPage.items || []).slice(0, 20)" :key="item.id">
|
||||
<td><span class="badge neutral">{{ item.category || "-" }}</span></td>
|
||||
<td class="mono">{{ item.type || "-" }}</td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.message || "-" }}</td>
|
||||
<td>{{ item.createdAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.systemLogPage.items || []).length"><td colspan="5">暂无系统日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>分类</th><th>类型</th><th>状态</th><th>消息</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in (ctx.systemLogPage.items || []).slice(0, 20)" :key="item.id">
|
||||
<td><span class="badge neutral">{{ item.category || "-" }}</span></td>
|
||||
<td class="mono">{{ item.type || "-" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.message || "-" }}</td>
|
||||
<td>{{ ctx.formatDateTime(item.createdAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.systemLogPage.items || []).length"><td colspan="5">暂无系统日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -13,28 +13,38 @@ defineProps<{ ctx: any }>();
|
||||
</div>
|
||||
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
<td>{{ item.category || item.categoryId }}</td>
|
||||
<td>{{ item.proxyMode }}</td>
|
||||
<td>
|
||||
<span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.labelStatus(ctx.endpointStatus(item)) }}</span>
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="health-kpis compact">
|
||||
<article><span>平均延迟</span><strong>{{ ctx.averageLatency(ctx.endpoints.map((item: any) => ctx.sourceLatency(item))) }}ms</strong></article>
|
||||
<article><span>总接口</span><strong>{{ ctx.endpoints.length }}</strong></article>
|
||||
<article><span>健康接口</span><strong>{{ ctx.healthyEndpointCount }}</strong></article>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>实时延迟</th><th>最近检测</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
<td>{{ item.category || item.categoryId }}</td>
|
||||
<td>{{ item.proxyMode }}</td>
|
||||
<td>
|
||||
<span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.labelStatus(ctx.endpointStatus(item)) }}</span>
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td><strong :class="['latency-value', ctx.sourceLatency(item) >= 1500 ? 'slow' : '']">{{ ctx.sourceLatency(item) }}ms</strong></td>
|
||||
<td>{{ ctx.formatDateTime(ctx.sourceCheckedAt(item)) }}</td>
|
||||
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="8">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -5,25 +5,43 @@ defineProps<{ ctx: any }>();
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>媒体/数据源</h2><button class="btn primary" @click="ctx.checkSources">批量检测</button></div>
|
||||
<div v-for="cat in ctx.sourceCategories" :key="cat.id || cat.name" class="source-group">
|
||||
<h3>{{ cat.name || cat.id }} <span class="badge">{{ cat.subcategories?.length || 0 }}</span></h3>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>媒体/数据源</h2>
|
||||
<p class="muted">服务端每 20 秒独立检测所有启用接口,表格展示最近一次状态与延迟。</p>
|
||||
</div>
|
||||
<button class="btn primary" @click="ctx.checkSources">批量检测</button>
|
||||
</div>
|
||||
|
||||
<div class="health-kpis">
|
||||
<article><span>可用率</span><strong>{{ ctx.sourceAvailability || 0 }}%</strong></article>
|
||||
<article><span>平均延迟</span><strong>{{ ctx.sourceAverageLatency || 0 }}ms</strong></article>
|
||||
<article><span>最大延迟</span><strong>{{ ctx.sourceMaxLatency || 0 }}ms</strong></article>
|
||||
<article><span>最近检测</span><strong>{{ ctx.formatDateTime(ctx.sourceLastCheckedAt) }}</strong></article>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>名称</th><th>描述</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
|
||||
<thead><tr><th>名称</th><th>分类</th><th>模式</th><th>状态</th><th>延迟</th><th>最近检测</th><th>URL</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="src in cat.subcategories || []" :key="src.id || src.sourceId">
|
||||
<td>{{ src.name }}</td>
|
||||
<td class="hash">{{ src.description || "-" }}</td>
|
||||
<tr v-for="src in ctx.sourceRows" :key="src.id || src.sourceId">
|
||||
<td>
|
||||
<strong>{{ src.name }}</strong>
|
||||
<small class="muted block">{{ src.description || "-" }}</small>
|
||||
</td>
|
||||
<td>{{ src.categoryName || src.categoryId || "-" }}</td>
|
||||
<td>{{ src.proxyMode || src.proxy_mode || "client_direct" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(src.health?.status || src.lastStatus)]">{{ src.health?.status || src.lastStatus || "unknown" }}</span></td>
|
||||
<td>{{ src.health?.latency_ms ?? src.lastLatencyMs ?? 0 }}ms</td>
|
||||
<td><span :class="['badge', ctx.statusTone(src.status)]">{{ ctx.labelStatus(src.status) }}</span></td>
|
||||
<td><strong :class="['latency-value', Number(src.latencyMs) >= 1500 ? 'slow' : '']">{{ src.latencyMs || 0 }}ms</strong></td>
|
||||
<td>{{ ctx.formatDateTime(src.checkedAt) }}</td>
|
||||
<td class="hash">{{ src.api_url || src.urlTemplate || src.apiUrl }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.sourceRows.length === 0"><td colspan="7">暂无接口源,可从旧 media-types.json 导入或手动添加。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="ctx.sourceCategories.length === 0" class="empty-state">暂无接口源,可从旧 media-types.json 导入或手动添加。</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel editor-panel">
|
||||
<h2>添加/覆盖接口</h2>
|
||||
<label>ID<input v-model="ctx.sourceDraft.sourceId" /></label>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import Image from "primevue/image";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Message from "primevue/message";
|
||||
import Textarea from "primevue/textarea";
|
||||
|
||||
const props = defineProps<{ ctx: any }>();
|
||||
const syncOutputRef = ref<HTMLElement | null>(null);
|
||||
@@ -188,22 +194,48 @@ tabs.splice(tabs.length - 1, 0, { id: "logs", label: "日志中心", icon: ListC
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>站点品牌</h2>
|
||||
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
|
||||
</div>
|
||||
<div class="brand-preview">
|
||||
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
|
||||
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
|
||||
<strong>{{ ctx.branding.developerName }}</strong>
|
||||
</div>
|
||||
<label>站点图标 URL<input v-model="ctx.branding.siteIconUrl" /></label>
|
||||
<label>开发者头像 URL<input v-model="ctx.branding.developerAvatarUrl" /></label>
|
||||
<label>开发者名称<input v-model="ctx.branding.developerName" /></label>
|
||||
<label>反馈邮箱<input v-model="ctx.branding.feedbackEmail" /></label>
|
||||
<button class="btn primary" @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</button>
|
||||
</section>
|
||||
<Card class="panel editor-panel brand-settings-card">
|
||||
<template #title>站点品牌</template>
|
||||
<template #subtitle>统一控制前台门户、后台标题、图标与开发者信息</template>
|
||||
<template #content>
|
||||
<div class="brand-preview prime-brand-preview">
|
||||
<Image :src="ctx.branding.logoUrl || ctx.branding.siteIconUrl" alt="应用 Logo" width="52" preview />
|
||||
<Image :src="ctx.branding.developerAvatarUrl" alt="开发者头像" width="52" preview />
|
||||
<div>
|
||||
<strong>{{ ctx.branding.siteName || "YMhut Box" }}</strong>
|
||||
<span>{{ ctx.branding.adminTitle || "YMhut 统一管理台" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Message severity="info" :closable="false">保存后前台门户、后台管理台、浏览器标题和 favicon 会使用这里的配置。</Message>
|
||||
<div class="form-grid brand-form-grid">
|
||||
<label>站点名称<InputText v-model="ctx.branding.siteName" /></label>
|
||||
<label>开发者名称<InputText v-model="ctx.branding.developerName" /></label>
|
||||
<label class="wide">前台标题<InputText v-model="ctx.branding.portalTitle" /></label>
|
||||
<label class="wide">前台副标题<Textarea v-model="ctx.branding.portalSubtitle" rows="2" autoResize /></label>
|
||||
<label class="wide">后台标题<InputText v-model="ctx.branding.adminTitle" /></label>
|
||||
<label class="wide">后台副标题<Textarea v-model="ctx.branding.adminSubtitle" rows="2" autoResize /></label>
|
||||
<label>浏览器图标 URL<InputText v-model="ctx.branding.siteIconUrl" /></label>
|
||||
<label>应用 Logo URL<InputText v-model="ctx.branding.logoUrl" /></label>
|
||||
<label>开发者头像 URL<InputText v-model="ctx.branding.developerAvatarUrl" /></label>
|
||||
<label>反馈邮箱<InputText v-model="ctx.branding.feedbackEmail" /></label>
|
||||
</div>
|
||||
<div class="brand-preview-grid">
|
||||
<div>
|
||||
<span>前台预览</span>
|
||||
<strong>{{ ctx.branding.portalTitle }}</strong>
|
||||
<small>{{ ctx.branding.portalSubtitle }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>后台预览</span>
|
||||
<strong>{{ ctx.branding.adminTitle }}</strong>
|
||||
<small>{{ ctx.branding.adminSubtitle }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</Button>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
|
||||
Reference in New Issue
Block a user