This commit is contained in:
QWQLwToo
2026-07-12 09:12:12 +08:00
parent b22914b5de
commit ef25f8875c
43 changed files with 2797 additions and 709 deletions
+93
View File
@@ -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",
+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" />
@@ -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">