feat: complete 2.0.7.12 platform overhaul
This commit is contained in:
+1685
-37
File diff suppressed because it is too large
Load Diff
@@ -6,21 +6,26 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
"validate:build": "node ../../scripts/validate-admin-build.mjs dist",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"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",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
"@types/node": "^26.2.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
Database,
|
||||
FileJson,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
Menu,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
import Button from "primevue/button";
|
||||
import ConfirmDialog from "primevue/confirmdialog";
|
||||
@@ -21,22 +15,27 @@ import Toast from "primevue/toast";
|
||||
import Toolbar from "primevue/toolbar";
|
||||
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
|
||||
import { createAuthStore } from "./stores/auth";
|
||||
import { createDashboardStore } from "./stores/dashboard";
|
||||
import { createDashboardStore, normalizeDashboardData } from "./stores/dashboard";
|
||||
import { createFeedbackStore } from "./stores/feedback";
|
||||
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"));
|
||||
const EndpointsView = defineAsyncComponent(() => import("./views/EndpointsView.vue"));
|
||||
const FeedbacksView = defineAsyncComponent(() => import("./views/FeedbacksView.vue"));
|
||||
const LegacyJsonView = defineAsyncComponent(() => import("./views/LegacyJsonView.vue"));
|
||||
const ReleasesView = defineAsyncComponent(() => import("./views/ReleasesView.vue"));
|
||||
const SourcesView = defineAsyncComponent(() => import("./views/SourcesView.vue"));
|
||||
const SystemView = defineAsyncComponent(() => import("./views/SystemView.vue"));
|
||||
const AuditLogView = defineAsyncComponent(() => import("./views/AuditLogView.vue"));
|
||||
import { adminNavigation, adminNavigationGroups, prefetchAdminRoute, prefetchPriorityAdminRoutes } from "./router";
|
||||
import { createEventBatcher } from "./utils/eventBatcher";
|
||||
import { createLatestRequest } from "./utils/latestRequest";
|
||||
import type {
|
||||
AuditViewContext,
|
||||
DashboardViewContext,
|
||||
EndpointsViewContext,
|
||||
FeedbackViewContext,
|
||||
LegacyViewContext,
|
||||
LoginViewContext,
|
||||
ReleasesViewContext,
|
||||
SourcesViewContext,
|
||||
SystemViewContext,
|
||||
} from "./types/admin";
|
||||
|
||||
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "logs" | "audit";
|
||||
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||
@@ -50,16 +49,7 @@ type Captcha = {
|
||||
};
|
||||
|
||||
type AuthBootstrap = {
|
||||
isDefaultPassword: boolean;
|
||||
defaultUsername: string;
|
||||
defaultPassword: string;
|
||||
};
|
||||
|
||||
type RouteItem = {
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
isDefaultPassword?: boolean;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
@@ -70,13 +60,22 @@ const loginPending = ref(false);
|
||||
const captchaPending = ref(false);
|
||||
const toast = ref<ToastState | null>(null);
|
||||
const autoRefreshPaused = ref(false);
|
||||
const dashboardPending = ref(false);
|
||||
const dashboardError = ref("");
|
||||
const databaseFormEditing = ref(false);
|
||||
const mailConfigEditing = ref(false);
|
||||
const mobileNavOpen = ref(false);
|
||||
let refreshTimer: number | undefined;
|
||||
let systemRefreshTimer: number | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
let events: EventSource | null = null;
|
||||
let eventsConnected = false;
|
||||
let systemLoadPromise: Promise<void> | null = null;
|
||||
let captchaRequestSerial = 0;
|
||||
let uploadController: AbortController | null = null;
|
||||
const feedbackListRequest = createLatestRequest();
|
||||
const feedbackDetailRequest = createLatestRequest();
|
||||
const adminEventBatcher = createEventBatcher(250, flushAdminEvents);
|
||||
let priorityPrefetchScheduled = false;
|
||||
|
||||
const authStore = createAuthStore();
|
||||
const dashboardStore = createDashboardStore();
|
||||
@@ -94,27 +93,8 @@ const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, moda
|
||||
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
||||
const { database, databaseConfig, databaseLastSync, databaseSyncJob, databaseSyncOutput, healthSnapshot, auditLogs, auditPage, systemLogPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
|
||||
|
||||
const routes: RouteItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||
{ path: "/admin/feedbacks", label: "反馈工单", description: "旧客户端反馈与处理流转", icon: MessageSquareText },
|
||||
{ path: "/admin/releases", label: "发布与日志", description: "发布包、版本公告和兼容日志", icon: ArrowDownToLine },
|
||||
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "可视化维护 update-info.json", icon: FileJson },
|
||||
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
|
||||
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
|
||||
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
|
||||
{ path: "/admin/audit", label: "审计日志", description: "操作审计、登录记录与安全事件", icon: ShieldCheck },
|
||||
{ path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
|
||||
];
|
||||
|
||||
const navGroups = [
|
||||
{ label: "概览", items: routes.filter((item) => ["/admin/dashboard"].includes(item.path)) },
|
||||
{ label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) },
|
||||
{ label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) },
|
||||
{ label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) },
|
||||
{ label: "系统运维", items: routes.filter((item) => ["/admin/audit", "/admin/system"].includes(item.path)) },
|
||||
];
|
||||
|
||||
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
|
||||
const navGroups = adminNavigationGroups;
|
||||
const pageMeta = computed(() => adminNavigation.find((item) => item.path === currentPath.value) || adminNavigation[0]);
|
||||
const activeLegacyName = computed<LegacyName | null>(() => {
|
||||
if (currentPath.value.endsWith("/update-info")) return "update-info";
|
||||
if (currentPath.value.endsWith("/media-types")) return "media-types";
|
||||
@@ -127,8 +107,8 @@ const heartbeats = computed(() => dashboard.value?.heartbeats || []);
|
||||
const clientCalls = computed(() => dashboard.value?.clientCalls || []);
|
||||
const releasePackages = computed(() => releases.value?.packages || []);
|
||||
const sourceCategories = computed(() => sources.value?.categories || []);
|
||||
const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length);
|
||||
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
|
||||
const visibleEndpointCount = computed(() => sourceRows.value.filter((item: any) => item.enabled && item.clientVisible).length);
|
||||
const healthyEndpointCount = computed(() => sourceRows.value.filter((item: any) => ["ok", "redirected"].includes(endpointStatus(item))).length);
|
||||
const latestNotice = computed(() => releaseNotices.value[0] || null);
|
||||
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
|
||||
const activeMediaCategory = computed(() => {
|
||||
@@ -136,7 +116,9 @@ 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) => ({
|
||||
const sourceRows = computed(() => Array.isArray(dashboard.value?.sourceRows) && dashboard.value.sourceRows.length
|
||||
? dashboard.value.sourceRows
|
||||
: sourceCategories.value.flatMap((cat: any) => (cat.subcategories || []).map((src: any) => ({
|
||||
...src,
|
||||
categoryName: cat.name || cat.id || src.categoryName || src.categoryId || "未分类",
|
||||
status: endpointStatus(src),
|
||||
@@ -184,88 +166,9 @@ const averageLatencyRows = computed(() => {
|
||||
});
|
||||
const isAverageLatencyChartEmpty = computed(() => averageLatencyRows.value.length === 0);
|
||||
|
||||
const heartbeatOption = computed(() => ({
|
||||
animation: true,
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
boundaryGap: averageLatencyRows.value.length <= 1,
|
||||
data: averageLatencyRows.value.map((item: any) => item.label),
|
||||
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||
axisLabel: { color: "#64748b" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "ms",
|
||||
min: 0,
|
||||
axisLine: { lineStyle: { color: "#cbd5e1" } },
|
||||
axisLabel: { color: "#64748b" },
|
||||
splitLine: { lineStyle: { color: "#e5e7eb" } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "接口延迟",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 7,
|
||||
connectNulls: true,
|
||||
areaStyle: { opacity: 0.18 },
|
||||
data: averageLatencyRows.value.map((item: any) => item.latency),
|
||||
color: "#2563eb",
|
||||
lineStyle: { width: 3 },
|
||||
emphasis: { focus: "series" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const healthOption = computed(() => {
|
||||
const data = healthStatusOrder.map((item) => ({
|
||||
name: item.label,
|
||||
value: Number(sourceHealth.value?.[item.key] || 0),
|
||||
itemStyle: { color: item.color },
|
||||
})).filter((item) => item.value > 0);
|
||||
return {
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
name: "接口健康",
|
||||
type: "pie",
|
||||
radius: ["48%", "72%"],
|
||||
data: data.length ? data : [{ name: "暂无数据", value: 1, itemStyle: { color: "#cbd5e1" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const feedbackOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 34, right: 12, top: 20, bottom: 28 },
|
||||
xAxis: { type: "category", data: objectEntries(feedbackStatus.value).map((item) => item.name) },
|
||||
yAxis: { type: "value", splitLine: { lineStyle: { color: "#e5e7eb" } } },
|
||||
series: [{ name: "工单", type: "bar", data: objectEntries(feedbackStatus.value).map((item) => item.value), color: "#0f766e" }],
|
||||
}));
|
||||
|
||||
const availabilityOption = computed(() => {
|
||||
const total = Number(kpis.value.sourceTotal || 0);
|
||||
const ok = Number(sourceHealth.value.ok || 0) + Number(sourceHealth.value.redirected || 0);
|
||||
const value = total ? Math.round((ok / total) * 100) : 0;
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: "gauge",
|
||||
progress: { show: true, width: 12 },
|
||||
axisLine: { lineStyle: { width: 12 } },
|
||||
axisLabel: { distance: 16 },
|
||||
pointer: { width: 4 },
|
||||
detail: { formatter: "{value}%", fontSize: 24 },
|
||||
data: [{ value, name: "可用率" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const hasSourceHealthData = computed(() => Object.values(sourceHealth.value).some((value) => Number(value) > 0));
|
||||
const hasFeedbackStatusData = computed(() => Object.values(feedbackStatus.value).some((value) => Number(value) > 0));
|
||||
const dashboardWarnings = computed(() => Array.isArray(dashboard.value?.warnings) ? dashboard.value.warnings.map(String) : []);
|
||||
|
||||
const healthStatusOrder = [
|
||||
{ key: "ok", label: "正常", color: "#16a34a" },
|
||||
@@ -275,79 +178,188 @@ const healthStatusOrder = [
|
||||
{ key: "unknown", label: "未知", color: "#94a3b8" },
|
||||
];
|
||||
|
||||
const viewContext = computed(() => ({
|
||||
activeLegacyLabel: activeLegacyLabel.value,
|
||||
activeLegacyName: activeLegacyName.value,
|
||||
addFeedbackComment,
|
||||
addMediaCategory,
|
||||
addMediaSubcategory,
|
||||
addUpdateMirror,
|
||||
applyLegacyModal,
|
||||
auditPage,
|
||||
auditLogs: auditLogs.value,
|
||||
autoRefreshPaused: autoRefreshPaused.value,
|
||||
availabilityOption: availabilityOption.value,
|
||||
const healthDistributionRows = computed(() => {
|
||||
const total = healthStatusOrder.reduce((sum, item) => sum + Number(sourceHealth.value?.[item.key] || 0), 0);
|
||||
return healthStatusOrder.map((item) => {
|
||||
const value = Number(sourceHealth.value?.[item.key] || 0);
|
||||
return { ...item, value, percent: total ? Math.round((value / total) * 100) : 0 };
|
||||
}).filter((item) => item.value > 0);
|
||||
});
|
||||
|
||||
const feedbackDistributionRows = computed(() => {
|
||||
const rows = objectEntries(feedbackStatus.value);
|
||||
const max = Math.max(1, ...rows.map((item) => item.value));
|
||||
return rows.map((item) => ({ ...item, percent: Math.round((item.value / max) * 100) }));
|
||||
});
|
||||
|
||||
const loginViewContext = computed<LoginViewContext>(() => ({
|
||||
branding,
|
||||
changePassword,
|
||||
captcha: captcha.value,
|
||||
captchaPending: captchaPending.value,
|
||||
login,
|
||||
loginForm,
|
||||
loginPending: loginPending.value,
|
||||
refreshCaptcha,
|
||||
}));
|
||||
|
||||
const dashboardViewContext = computed<DashboardViewContext>(() => ({
|
||||
autoRefreshPaused: autoRefreshPaused.value,
|
||||
averageLatencyRows: averageLatencyRows.value,
|
||||
checkSources,
|
||||
clientCalls: clientCalls.value,
|
||||
commentDraft,
|
||||
copyEndpointToSource,
|
||||
database: database.value,
|
||||
databaseConfig: databaseConfig.value,
|
||||
databaseConfigCollapsed: databaseConfigCollapsed.value,
|
||||
databaseFormEditing: databaseFormEditing.value,
|
||||
databaseForm,
|
||||
databaseLastSync: databaseLastSync.value,
|
||||
databaseSyncJob: databaseSyncJob.value,
|
||||
databaseSyncOutput: databaseSyncOutput.value,
|
||||
databaseSyncStatusLabel,
|
||||
databaseSyncDirectionLabel,
|
||||
databaseSyncTableCount,
|
||||
databaseConfigSummary,
|
||||
deleteEndpoint,
|
||||
editDatabaseConfig,
|
||||
endpointStatus,
|
||||
endpoints: endpoints.value,
|
||||
feedbackFilters,
|
||||
feedbackOption: feedbackOption.value,
|
||||
feedbackPage: feedbackPage.value,
|
||||
feedbackUpdate,
|
||||
feedbackSelectedCodes: feedbackSelectedCodes.value,
|
||||
feedbackDetailTab: feedbackDetailTab.value,
|
||||
feedbackTagInput: feedbackTagInput.value,
|
||||
setFeedbackDetailTab: (tab: string) => { feedbackDetailTab.value = tab as any; },
|
||||
setFeedbackTagInput: (val: string) => { feedbackTagInput.value = val; },
|
||||
addFeedbackTag,
|
||||
removeFeedbackTag,
|
||||
toggleFeedbackCode,
|
||||
toggleAllFeedbackCodes,
|
||||
bulkUpdateFeedbacks,
|
||||
formatBytes,
|
||||
dashboardError: dashboardError.value,
|
||||
dashboardPending: dashboardPending.value,
|
||||
dashboardWarnings: dashboardWarnings.value,
|
||||
feedbackDistributionRows: feedbackDistributionRows.value,
|
||||
formatDateTime,
|
||||
formatHealthOutput,
|
||||
healthOption: healthOption.value,
|
||||
healthSnapshot: healthSnapshot.value,
|
||||
hasFeedbackStatusData: hasFeedbackStatusData.value,
|
||||
hasSourceHealthData: hasSourceHealthData.value,
|
||||
healthDistributionRows: healthDistributionRows.value,
|
||||
healthyEndpointCount: healthyEndpointCount.value,
|
||||
heartbeatOption: heartbeatOption.value,
|
||||
heartbeats: heartbeats.value,
|
||||
isHeartbeatChartEmpty: isHeartbeatChartEmpty.value,
|
||||
isAverageLatencyChartEmpty: isAverageLatencyChartEmpty.value,
|
||||
importNotices,
|
||||
kpis: kpis.value,
|
||||
labelStatus,
|
||||
labelPriority,
|
||||
latestNotice: latestNotice.value,
|
||||
loadSystemLogs,
|
||||
sourceAvailability: sourceAvailability.value,
|
||||
sourceAverageLatency: sourceAverageLatency.value,
|
||||
sourceCheckJobs: sourceCheckJobs.value,
|
||||
sourceLastCheckedAt: sourceLastCheckedAt.value,
|
||||
sourceMaxLatency: sourceMaxLatency.value,
|
||||
sourceRows: sourceRows.value,
|
||||
statusTone,
|
||||
systemLogPage,
|
||||
toggleAutoRefresh,
|
||||
visibleEndpointCount: visibleEndpointCount.value,
|
||||
}));
|
||||
|
||||
const feedbackViewContext = computed<FeedbackViewContext>(() => ({
|
||||
addFeedbackComment,
|
||||
addFeedbackTag,
|
||||
bulkUpdateFeedbacks,
|
||||
commentDraft,
|
||||
feedbackDetailTab: feedbackDetailTab.value,
|
||||
feedbackFilters,
|
||||
feedbackPage: feedbackPage.value,
|
||||
feedbackSelectedCodes: feedbackSelectedCodes.value,
|
||||
feedbackTagInput: feedbackTagInput.value,
|
||||
feedbackUpdate,
|
||||
labelPriority,
|
||||
labelStatus,
|
||||
loadFeedbacks,
|
||||
openFeedback,
|
||||
removeFeedbackTag,
|
||||
retryFeedbackMail,
|
||||
saveFeedbackUpdate,
|
||||
selectedFeedback: selectedFeedback.value,
|
||||
setFeedbackDetailTab: (tab: string) => { feedbackDetailTab.value = tab as typeof feedbackDetailTab.value; },
|
||||
setFeedbackTagInput: (value: string) => { feedbackTagInput.value = value; },
|
||||
statusTone,
|
||||
toggleAllFeedbackCodes,
|
||||
toggleFeedbackCode,
|
||||
}));
|
||||
|
||||
const releasesViewContext = computed<ReleasesViewContext>(() => ({
|
||||
cancelUpload,
|
||||
formatBytes,
|
||||
noticeDraft,
|
||||
onPackageSelected,
|
||||
openNotice,
|
||||
releaseNotices: releaseNotices.value,
|
||||
releasePackages: releasePackages.value,
|
||||
releases: releases.value,
|
||||
restoreNotice,
|
||||
saveNotice,
|
||||
selectedNotice: selectedNotice.value,
|
||||
uploadDraft,
|
||||
uploadPackage,
|
||||
validateNotice,
|
||||
}));
|
||||
|
||||
const legacyViewContext = computed<LegacyViewContext>(() => ({
|
||||
activeLegacyLabel: activeLegacyLabel.value,
|
||||
activeLegacyName: activeLegacyName.value || "update-info",
|
||||
activeMediaCategory: activeMediaCategory.value,
|
||||
activeMediaCategoryIndex: activeMediaCategoryIndex.value,
|
||||
applyLegacyModal,
|
||||
closeLegacyModal,
|
||||
legacyDocuments,
|
||||
legacyDrafts,
|
||||
legacyModal,
|
||||
activeMediaCategoryIndex: activeMediaCategoryIndex.value,
|
||||
activeMediaCategory: activeMediaCategory.value,
|
||||
legacySync: legacySync.value,
|
||||
openMediaCategoryModal,
|
||||
openMediaSubcategoryModal,
|
||||
openUpdateMirrorModal,
|
||||
pretty,
|
||||
removeItem,
|
||||
restoreLegacy,
|
||||
saveLegacy,
|
||||
selectMediaCategory,
|
||||
updateLegacyRawFromForm,
|
||||
validateLegacy,
|
||||
}));
|
||||
|
||||
const sourcesViewContext = computed<SourcesViewContext>(() => ({
|
||||
checkSources,
|
||||
formatDateTime,
|
||||
labelStatus,
|
||||
saveSource,
|
||||
sourceAvailability: sourceAvailability.value,
|
||||
sourceAverageLatency: sourceAverageLatency.value,
|
||||
sourceDraft,
|
||||
sourceLastCheckedAt: sourceLastCheckedAt.value,
|
||||
sourceMaxLatency: sourceMaxLatency.value,
|
||||
sourceRows: sourceRows.value,
|
||||
statusTone,
|
||||
}));
|
||||
|
||||
const endpointsViewContext = computed<EndpointsViewContext>(() => ({
|
||||
averageLatency,
|
||||
copyEndpointToSource,
|
||||
deleteEndpoint,
|
||||
endpoints: endpoints.value,
|
||||
endpointStatus,
|
||||
formatDateTime,
|
||||
healthyEndpointCount: healthyEndpointCount.value,
|
||||
labelStatus,
|
||||
sourceCheckedAt,
|
||||
sourceLatency,
|
||||
statusTone,
|
||||
visibleEndpointCount: visibleEndpointCount.value,
|
||||
}));
|
||||
|
||||
const auditViewContext = computed<AuditViewContext>(() => ({
|
||||
auditMessage,
|
||||
auditPage,
|
||||
auditTypeLabel,
|
||||
loadAudit,
|
||||
selectAuditLog,
|
||||
setAuditPage,
|
||||
}));
|
||||
|
||||
const systemViewContext = computed<SystemViewContext>(() => ({
|
||||
auditMessage,
|
||||
auditPage,
|
||||
auditTypeLabel,
|
||||
branding,
|
||||
changePassword,
|
||||
database: database.value,
|
||||
databaseConfig: databaseConfig.value,
|
||||
databaseConfigCollapsed: databaseConfigCollapsed.value,
|
||||
databaseConfigSummary,
|
||||
databaseForm,
|
||||
databaseFormEditing: databaseFormEditing.value,
|
||||
databaseLastSync: databaseLastSync.value,
|
||||
databaseSyncDirectionLabel,
|
||||
databaseSyncJob: databaseSyncJob.value,
|
||||
databaseSyncOutput: databaseSyncOutput.value,
|
||||
databaseSyncStatusLabel,
|
||||
databaseSyncTableCount,
|
||||
editDatabaseConfig,
|
||||
healthSnapshot: healthSnapshot.value,
|
||||
labelStatus,
|
||||
legacySync: legacySync.value || {},
|
||||
legacySyncMode: legacySyncMode.value,
|
||||
loadAudit,
|
||||
loadBranding,
|
||||
loadFeedbacks,
|
||||
loadMigrationStatus,
|
||||
loadSystemLogs,
|
||||
mailConfig,
|
||||
@@ -355,84 +367,55 @@ const viewContext = computed(() => ({
|
||||
markDatabaseFormEditing,
|
||||
markMailConfigEditing,
|
||||
migrationStatus: migrationStatus.value,
|
||||
loadMailConfig,
|
||||
reloadDatabaseConfig,
|
||||
reloadMailConfig,
|
||||
saveDatabase,
|
||||
saveBranding,
|
||||
saveMailConfig,
|
||||
testMail,
|
||||
retryFeedbackMail,
|
||||
navigate,
|
||||
noticeDraft,
|
||||
onPackageSelected,
|
||||
openFeedback,
|
||||
openNotice,
|
||||
passwordForm,
|
||||
pretty,
|
||||
previewLegacySync,
|
||||
removeItem,
|
||||
releaseNotices: releaseNotices.value,
|
||||
releasePackages: releasePackages.value,
|
||||
releases: releases.value,
|
||||
restoreLegacy,
|
||||
restoreNotice,
|
||||
refreshPreflight,
|
||||
reloadDatabaseConfig,
|
||||
reloadMailConfig,
|
||||
runLegacySync,
|
||||
saveFeedbackUpdate,
|
||||
saveLegacy,
|
||||
saveNotice,
|
||||
saveSource,
|
||||
selectedFeedback: selectedFeedback.value,
|
||||
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,
|
||||
saveBranding,
|
||||
saveDatabase,
|
||||
saveMailConfig,
|
||||
selectAuditLog,
|
||||
selectSystemLog,
|
||||
setAuditPage,
|
||||
setSystemLogPage,
|
||||
setSystemTab,
|
||||
statusTone,
|
||||
syncDatabase,
|
||||
systemLogPage,
|
||||
systemTab: systemTab.value,
|
||||
setSystemTab,
|
||||
setAuditPage,
|
||||
setSystemLogPage,
|
||||
selectSystemLog,
|
||||
selectAuditLog,
|
||||
testDatabase,
|
||||
toggleAutoRefresh,
|
||||
openMediaCategoryModal,
|
||||
openMediaSubcategoryModal,
|
||||
openUpdateMirrorModal,
|
||||
selectMediaCategory,
|
||||
closeLegacyModal,
|
||||
updateLegacyRawFromForm,
|
||||
uploadDraft,
|
||||
uploadPackage,
|
||||
auditMessage,
|
||||
auditTypeLabel,
|
||||
validateLegacy,
|
||||
validateNotice,
|
||||
visibleEndpointCount: visibleEndpointCount.value,
|
||||
testMail,
|
||||
}));
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||
return adminFetch<T>(target, init, { csrf: csrf.value });
|
||||
const currentViewContext = computed(() => {
|
||||
switch (currentPath.value) {
|
||||
case "/admin/feedbacks": return feedbackViewContext.value;
|
||||
case "/admin/releases": return releasesViewContext.value;
|
||||
case "/admin/legacy/update-info":
|
||||
case "/admin/legacy/media-types": return legacyViewContext.value;
|
||||
case "/admin/sources": return sourcesViewContext.value;
|
||||
case "/admin/endpoints": return endpointsViewContext.value;
|
||||
case "/admin/audit": return auditViewContext.value;
|
||||
case "/admin/system": return systemViewContext.value;
|
||||
default: return dashboardViewContext.value;
|
||||
}
|
||||
});
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<T> {
|
||||
return adminFetch<T>(target, init, { csrf: csrf.value, ...options });
|
||||
}
|
||||
|
||||
function uploadWithProgress<T>(target: string, form: FormData, onProgress: (loaded: number, total: number) => void): Promise<T> {
|
||||
return uploadAdminFile<T>(target, form, { csrf: csrf.value }, (progress) => onProgress(progress.loaded, progress.total));
|
||||
function uploadWithProgress<T>(target: string, form: FormData, signal: AbortSignal, onProgress: (loaded: number, total: number) => void): Promise<T> {
|
||||
return uploadAdminFile<T>(target, form, { csrf: csrf.value, signal, timeoutMs: 30 * 60 * 1000 }, (progress) => onProgress(progress.loaded, progress.total));
|
||||
}
|
||||
|
||||
function normalizeAdminPath(value: string) {
|
||||
if (value === "/admin" || value === "/admin/") return "/admin/dashboard";
|
||||
if (value === "/") return "/admin/dashboard";
|
||||
if (["/admin/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(value)) return "/admin/system";
|
||||
if (["/admin/database", "/admin/health", "/admin/settings"].includes(value)) return "/admin/system";
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -443,6 +426,7 @@ function normalizeSystemTab(value: unknown): SystemTab {
|
||||
}
|
||||
|
||||
function navigate(next: string) {
|
||||
mobileNavOpen.value = false;
|
||||
if (currentPath.value === next) {
|
||||
void load();
|
||||
return;
|
||||
@@ -536,7 +520,9 @@ async function login() {
|
||||
csrf.value = data.csrfToken;
|
||||
sessionStorage.setItem("ymhut.csrf", csrf.value);
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
await loadAuthBootstrap();
|
||||
connectAdminEvents();
|
||||
schedulePriorityPrefetch();
|
||||
navigate("/admin/dashboard");
|
||||
} catch (error) {
|
||||
const message = toChineseError(error instanceof Error ? error.message : String(error));
|
||||
@@ -570,12 +556,7 @@ async function load() {
|
||||
navigate("/admin/login");
|
||||
return;
|
||||
}
|
||||
if (currentPath.value === "/admin/dashboard") await Promise.all([
|
||||
loadDashboard(),
|
||||
loadSources().catch(() => undefined),
|
||||
loadEndpoints().catch(() => undefined),
|
||||
loadSourceCheckJobs().catch(() => undefined),
|
||||
]);
|
||||
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||
if (currentPath.value === "/admin/feedbacks") await loadFeedbacks();
|
||||
if (currentPath.value === "/admin/releases") await loadReleases();
|
||||
if (currentPath.value === "/admin/sources") await loadSources();
|
||||
@@ -589,20 +570,63 @@ async function load() {
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
||||
dashboardPending.value = true;
|
||||
dashboardError.value = "";
|
||||
try {
|
||||
const data = await api<any>("/api/admin/dashboard/overview?window=24h");
|
||||
dashboard.value = normalizeDashboardData(data);
|
||||
sourceCheckJobs.value = Array.isArray(data.sourceCheckJobs) ? data.sourceCheckJobs : [];
|
||||
} catch (error) {
|
||||
dashboardError.value = toChineseError(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
} finally {
|
||||
dashboardPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystem(options: LoadSystemOptions = {}) {
|
||||
await Promise.all([
|
||||
loadDatabase({ preserveForm: options.preserveForms }),
|
||||
loadMailConfig({ preserveForm: options.preserveForms }),
|
||||
loadHealth(),
|
||||
loadAudit(),
|
||||
loadSystemLogs(),
|
||||
loadDatabaseSyncLatest(),
|
||||
loadMigrationStatus(),
|
||||
loadBranding(),
|
||||
]);
|
||||
if (systemLoadPromise) {
|
||||
await systemLoadPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = (() => {
|
||||
switch (systemTab.value) {
|
||||
case "database":
|
||||
return [loadDatabase({ preserveForm: options.preserveForms }), loadDatabaseSyncLatest()];
|
||||
case "migration":
|
||||
return [loadMigrationStatus()];
|
||||
case "sync":
|
||||
return [previewLegacySync()];
|
||||
case "security":
|
||||
return [loadMailConfig({ preserveForm: options.preserveForms }), loadBranding()];
|
||||
case "health":
|
||||
return [loadHealth()];
|
||||
case "logs":
|
||||
return [loadSystemLogs()];
|
||||
case "audit":
|
||||
return [loadAudit()];
|
||||
}
|
||||
})();
|
||||
|
||||
const pending = (async () => {
|
||||
const results = await Promise.allSettled(tasks);
|
||||
const rejected = results.find((item): item is PromiseRejectedResult => item.status === "rejected");
|
||||
if (rejected) throw rejected.reason;
|
||||
})();
|
||||
systemLoadPromise = pending;
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (systemLoadPromise === pending) systemLoadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreflight() {
|
||||
await guarded(async () => {
|
||||
healthSnapshot.value = await api("/api/admin/system/preflight", { method: "POST", body: "{}" });
|
||||
setToast("运行环境预检已刷新");
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFeedbacks(page?: number) {
|
||||
@@ -613,13 +637,15 @@ async function loadFeedbacks(page?: number) {
|
||||
if (feedbackFilters.priority) params.set("priority", feedbackFilters.priority);
|
||||
if ((feedbackFilters as any).category) params.set("category", (feedbackFilters as any).category);
|
||||
if ((feedbackFilters as any).assignee) params.set("assignee", (feedbackFilters as any).assignee);
|
||||
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
|
||||
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
||||
feedbackSelectedCodes.value = [];
|
||||
const data = await feedbackListRequest.run((signal) => api<{ page: any }>(`/api/admin/feedbacks?${params}`, {}, { signal }));
|
||||
if (!data) return;
|
||||
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
|
||||
feedbackSelectedCodes.value = [];
|
||||
}
|
||||
|
||||
async function openFeedback(item: any) {
|
||||
const data = await api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`);
|
||||
const data = await feedbackDetailRequest.run((signal) => api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`, {}, { signal }));
|
||||
if (!data) return;
|
||||
selectedFeedback.value = data.feedback;
|
||||
feedbackUpdate.status = data.feedback.status || "new";
|
||||
feedbackUpdate.priority = data.feedback.priority || "normal";
|
||||
@@ -788,6 +814,8 @@ async function uploadPackage() {
|
||||
}
|
||||
let completed = false;
|
||||
await guarded(async () => {
|
||||
uploadController?.abort();
|
||||
uploadController = new AbortController();
|
||||
const form = new FormData();
|
||||
form.append("file", uploadDraft.file as File);
|
||||
form.append("version", uploadDraft.version);
|
||||
@@ -801,7 +829,7 @@ async function uploadPackage() {
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
|
||||
await uploadWithProgress("/api/admin/releases/packages", form, (loaded, total) => {
|
||||
await uploadWithProgress("/api/admin/releases/packages", form, uploadController.signal, (loaded, total) => {
|
||||
uploadDraft.loadedBytes = loaded;
|
||||
uploadDraft.totalBytes = total;
|
||||
uploadDraft.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||
@@ -823,6 +851,7 @@ async function uploadPackage() {
|
||||
}
|
||||
}, 1200);
|
||||
}).finally(() => {
|
||||
uploadController = null;
|
||||
uploadDraft.uploading = false;
|
||||
if (!completed) {
|
||||
uploadDraft.progress = 0;
|
||||
@@ -833,6 +862,12 @@ async function uploadPackage() {
|
||||
});
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
if (!uploadDraft.uploading) return;
|
||||
uploadDraft.status = "正在取消";
|
||||
uploadController?.abort();
|
||||
}
|
||||
|
||||
async function validateLegacy(name: LegacyName) {
|
||||
const data = await api<{ document: any }>(`/api/admin/legacy/${name}/validate`, {
|
||||
method: "POST",
|
||||
@@ -1462,12 +1497,12 @@ async function changePassword() {
|
||||
passwordForm.currentPassword = "";
|
||||
passwordForm.newPassword = "";
|
||||
if (authBootstrap.value) authBootstrap.value.isDefaultPassword = data.isDefaultPassword;
|
||||
setToast(data.warning || "后台密码已修改,登录页将不再提示默认密码", data.warning ? "warn" : "success");
|
||||
setToast(data.warning || "后台密码已修改", data.warning ? "warn" : "success");
|
||||
});
|
||||
}
|
||||
|
||||
function endpointStatus(item: any) {
|
||||
return item.health?.status || item.lastStatus || "unknown";
|
||||
return item.health?.status || item.status || item.lastStatus || "unknown";
|
||||
}
|
||||
|
||||
function sourceLatency(item: any) {
|
||||
@@ -1671,70 +1706,91 @@ function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function schedulePriorityPrefetch() {
|
||||
if (priorityPrefetchScheduled || !csrf.value) return;
|
||||
priorityPrefetchScheduled = true;
|
||||
const run = () => void prefetchPriorityAdminRoutes();
|
||||
if ("requestIdleCallback" in window) {
|
||||
window.requestIdleCallback(run, { timeout: 1800 });
|
||||
} else {
|
||||
window.setTimeout(run, 500);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
void load();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
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 });
|
||||
if (!eventsConnected && document.visibilityState === "visible") void pollCurrentPage();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
watch(currentPath, () => {
|
||||
void load();
|
||||
schedulePriorityPrefetch();
|
||||
});
|
||||
|
||||
watch(systemTab, () => {
|
||||
if (currentPath.value === "/admin/system" && csrf.value) void guarded(() => loadSystem({ preserveForms: true }));
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
if (systemRefreshTimer) window.clearInterval(systemRefreshTimer);
|
||||
adminEventBatcher.cancel();
|
||||
events?.close();
|
||||
events = null;
|
||||
uploadController?.abort();
|
||||
uploadController = null;
|
||||
feedbackListRequest.cancel("unmounted");
|
||||
feedbackDetailRequest.cancel("unmounted");
|
||||
});
|
||||
|
||||
function connectAdminEvents() {
|
||||
if (!csrf.value || events) return;
|
||||
events = new EventSource("/api/admin/events", { withCredentials: true });
|
||||
const refreshCurrent = () => {
|
||||
if (autoRefreshPaused.value) return;
|
||||
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
|
||||
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
|
||||
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
|
||||
if (currentPath.value === "/admin/system") void loadSystem({ preserveForms: true });
|
||||
events.onopen = () => {
|
||||
eventsConnected = true;
|
||||
};
|
||||
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
|
||||
events.addEventListener(name, refreshCurrent);
|
||||
for (const name of ["source_check.item", "source_check.progress", "source_check.completed"]) {
|
||||
events.addEventListener(name, () => scheduleEventRefresh(name));
|
||||
}
|
||||
for (const name of ["source.changed", "release.changed", "branding.changed", "feedback.changed", "cache.invalidated"]) {
|
||||
events.addEventListener(name, () => scheduleEventRefresh(name));
|
||||
}
|
||||
events.addEventListener("source.changed", () => {
|
||||
if (autoRefreshPaused.value) return;
|
||||
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSources().catch(() => undefined)]);
|
||||
if (currentPath.value === "/admin/sources") void loadSources();
|
||||
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
|
||||
});
|
||||
events.addEventListener("release.changed", () => {
|
||||
if (autoRefreshPaused.value) return;
|
||||
if (currentPath.value === "/admin/dashboard") void loadDashboard();
|
||||
if (currentPath.value === "/admin/releases") void loadReleases();
|
||||
if (activeLegacyName.value) void loadLegacy(activeLegacyName.value);
|
||||
});
|
||||
events.addEventListener("branding.changed", () => {
|
||||
if (autoRefreshPaused.value) return;
|
||||
void loadBranding();
|
||||
});
|
||||
events.addEventListener("feedback.changed", () => {
|
||||
if (autoRefreshPaused.value) return;
|
||||
if (currentPath.value === "/admin/dashboard") void loadDashboard();
|
||||
if (currentPath.value === "/admin/feedbacks") void loadFeedbacks();
|
||||
});
|
||||
events.onerror = () => {
|
||||
eventsConnected = false;
|
||||
events?.close();
|
||||
events = null;
|
||||
window.setTimeout(connectAdminEvents, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleEventRefresh(kind: string) {
|
||||
adminEventBatcher.push(kind);
|
||||
}
|
||||
|
||||
function flushAdminEvents(kinds: ReadonlySet<string>) {
|
||||
if (autoRefreshPaused.value || document.visibilityState !== "visible") return;
|
||||
const hasSourceChange = [...kinds].some((item) => item.startsWith("source"));
|
||||
if (currentPath.value === "/admin/dashboard" && [...kinds].some((item) => item !== "branding.changed")) void loadDashboard();
|
||||
if (currentPath.value === "/admin/sources" && hasSourceChange) void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
|
||||
if (currentPath.value === "/admin/endpoints" && hasSourceChange) void loadEndpoints();
|
||||
if (currentPath.value === "/admin/releases" && kinds.has("release.changed")) void loadReleases();
|
||||
if (currentPath.value === "/admin/feedbacks" && kinds.has("feedback.changed")) void loadFeedbacks();
|
||||
if (currentPath.value === "/admin/system") void loadSystem({ preserveForms: true });
|
||||
if (activeLegacyName.value && kinds.has("release.changed")) void loadLegacy(activeLegacyName.value);
|
||||
if (kinds.has("branding.changed")) void loadBranding();
|
||||
}
|
||||
|
||||
async function pollCurrentPage() {
|
||||
if (autoRefreshPaused.value || !csrf.value) return;
|
||||
if (currentPath.value === "/admin/dashboard") return loadDashboard();
|
||||
if (currentPath.value === "/admin/sources") return void await Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
|
||||
if (currentPath.value === "/admin/endpoints") return loadEndpoints();
|
||||
if (currentPath.value === "/admin/feedbacks") return loadFeedbacks();
|
||||
if (currentPath.value === "/admin/releases") return loadReleases();
|
||||
if (currentPath.value === "/admin/system") return loadSystem({ preserveForms: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1745,42 +1801,20 @@ function connectAdminEvents() {
|
||||
<div v-if="toast" :class="['toast', toast.type]">{{ toast.message }}</div>
|
||||
</Teleport>
|
||||
|
||||
<main v-if="currentPath === '/admin/login'" class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div>
|
||||
<p class="eyebrow">{{ branding.siteName }}</p>
|
||||
<h1>后台登录</h1>
|
||||
<p class="muted">{{ branding.adminSubtitle }}。验证码和密码都由服务端校验,登录后写操作继续要求 CSRF Token。</p>
|
||||
</div>
|
||||
<p v-if="authBootstrap?.isDefaultPassword" class="alert-line">
|
||||
当前使用默认账号:{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
|
||||
</p>
|
||||
<form class="form-stack" @submit.prevent="login">
|
||||
<label>账号<input v-model="loginForm.username" autocomplete="username" :disabled="loginPending" /></label>
|
||||
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" :disabled="loginPending" /></label>
|
||||
<label>
|
||||
验证码
|
||||
<div class="captcha-row">
|
||||
<input v-model="loginForm.captcha" :disabled="loginPending" autocomplete="off" />
|
||||
<button class="captcha-button" type="button" title="刷新验证码" :disabled="loginPending || captchaPending" @click="refreshCaptcha">
|
||||
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
|
||||
<span v-else>{{ captchaPending ? "加载中" : "刷新" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<Button class="full" type="submit" :label="loginPending ? '正在登录…' : '登录'" :loading="loginPending" :disabled="loginPending" />
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<RouterView v-if="currentPath === '/admin/login'" v-slot="{ Component }">
|
||||
<component :is="Component" :ctx="loginViewContext" />
|
||||
</RouterView>
|
||||
|
||||
<main v-else class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<button class="mobile-nav-backdrop" :class="{ open: mobileNavOpen }" aria-label="关闭导航" @click="mobileNavOpen = false"></button>
|
||||
<aside class="sidebar" :class="{ open: mobileNavOpen }">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">
|
||||
<img v-if="branding.logoUrl || branding.siteIconUrl" :src="branding.logoUrl || branding.siteIconUrl" :alt="branding.siteName" />
|
||||
<ShieldCheck v-else :size="22" />
|
||||
</span>
|
||||
<div><strong>{{ branding.adminTitle || "统一管理台" }}</strong><small>{{ branding.siteName }}</small></div>
|
||||
<button class="mobile-nav-close" title="关闭导航" @click="mobileNavOpen = false"><X :size="18" /></button>
|
||||
</div>
|
||||
<nav class="nav-groups">
|
||||
<section v-for="group in navGroups" :key="group.label" class="nav-group">
|
||||
@@ -1789,6 +1823,8 @@ function connectAdminEvents() {
|
||||
v-for="item in group.items"
|
||||
:key="item.path"
|
||||
:class="{ active: currentPath === item.path }"
|
||||
@mouseenter="void prefetchAdminRoute(item.path)"
|
||||
@focus="void prefetchAdminRoute(item.path)"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" />
|
||||
@@ -1802,8 +1838,9 @@ function connectAdminEvents() {
|
||||
<section class="workspace">
|
||||
<Toolbar class="topbar">
|
||||
<template #start>
|
||||
<div>
|
||||
<p class="eyebrow">{{ branding.adminSubtitle }}</p>
|
||||
<button class="mobile-nav-trigger" title="打开导航" @click="mobileNavOpen = true"><Menu :size="19" /></button>
|
||||
<div class="page-heading">
|
||||
<nav class="breadcrumbs" aria-label="面包屑"><span>管理后台</span><i>/</i><span>{{ pageMeta.group }}</span><i>/</i><strong>{{ pageMeta.label }}</strong></nav>
|
||||
<h1>{{ pageMeta.label }}</h1>
|
||||
<p class="muted">{{ pageMeta.description }}</p>
|
||||
</div>
|
||||
@@ -1811,18 +1848,17 @@ function connectAdminEvents() {
|
||||
<template #end>
|
||||
<div class="top-actions">
|
||||
<Tag v-if="loading" severity="warn" value="加载中" />
|
||||
<Button severity="secondary" outlined rounded @click="load"><RefreshCw :size="16" />刷新</Button>
|
||||
<Button severity="secondary" outlined @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" />
|
||||
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
|
||||
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
|
||||
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" />
|
||||
<AuditLogView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
|
||||
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
|
||||
<div v-if="authBootstrap?.isDefaultPassword" class="security-banner" role="status">
|
||||
<div><ShieldCheck :size="18" /><span>当前仍在使用初始管理员密码,请尽快修改以保护管理接口。</span></div>
|
||||
<button class="btn ghost compact" @click="setSystemTab('security')">前往安全设置</button>
|
||||
</div>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :ctx="currentViewContext" />
|
||||
</RouterView>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { uploadAdminFile } from "./admin";
|
||||
|
||||
describe("uploadAdminFile", () => {
|
||||
const originalXhr = globalThis.XMLHttpRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
FakeXMLHttpRequest.latest = null;
|
||||
globalThis.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.XMLHttpRequest = originalXhr;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports progress and resolves a successful response", async () => {
|
||||
const progress = vi.fn();
|
||||
const request = uploadAdminFile<{ ok: boolean }>(
|
||||
"/api/admin/releases/packages",
|
||||
new FormData(),
|
||||
{ csrf: "test-csrf", timeoutMs: 5000 },
|
||||
progress,
|
||||
);
|
||||
const xhr = FakeXMLHttpRequest.current();
|
||||
xhr.emitProgress(40, 100);
|
||||
xhr.respond(200, '{"ok":true}');
|
||||
|
||||
await expect(request).resolves.toEqual({ ok: true });
|
||||
expect(progress).toHaveBeenCalledWith({ loaded: 40, total: 100 });
|
||||
expect(xhr.timeout).toBe(5000);
|
||||
expect(xhr.headers.get("X-CSRF-Token")).toBe("test-csrf");
|
||||
});
|
||||
|
||||
it("maps a stable server error code", async () => {
|
||||
const request = uploadAdminFile(
|
||||
"/api/admin/releases/packages",
|
||||
new FormData(),
|
||||
{},
|
||||
vi.fn(),
|
||||
);
|
||||
FakeXMLHttpRequest.current().respond(500, '{"ok":false,"error":"PACKAGE_INDEX_FAILED"}');
|
||||
|
||||
await expect(request).rejects.toThrow("发布包已回滚,数据库索引更新失败");
|
||||
});
|
||||
|
||||
it("cancels the active request through AbortSignal", async () => {
|
||||
const controller = new AbortController();
|
||||
const request = uploadAdminFile(
|
||||
"/api/admin/releases/packages",
|
||||
new FormData(),
|
||||
{ signal: controller.signal },
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(request).rejects.toThrow("发布包上传已取消");
|
||||
expect(FakeXMLHttpRequest.current().aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an explicit timeout error", async () => {
|
||||
const request = uploadAdminFile(
|
||||
"/api/admin/releases/packages",
|
||||
new FormData(),
|
||||
{ timeoutMs: 1200 },
|
||||
vi.fn(),
|
||||
);
|
||||
FakeXMLHttpRequest.current().emitTimeout();
|
||||
|
||||
await expect(request).rejects.toThrow("上传超时,服务端未在限定时间内响应");
|
||||
});
|
||||
});
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static latest: FakeXMLHttpRequest | null = null;
|
||||
|
||||
static current() {
|
||||
if (!this.latest) throw new Error("XMLHttpRequest was not created");
|
||||
return this.latest;
|
||||
}
|
||||
|
||||
readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null };
|
||||
readonly headers = new Map<string, string>();
|
||||
status = 0;
|
||||
responseText = "";
|
||||
timeout = 0;
|
||||
withCredentials = false;
|
||||
aborted = false;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
ontimeout: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
FakeXMLHttpRequest.latest = this;
|
||||
}
|
||||
|
||||
open() {}
|
||||
|
||||
setRequestHeader(name: string, value: string) {
|
||||
this.headers.set(name, value);
|
||||
}
|
||||
|
||||
send() {}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
this.onabort?.();
|
||||
}
|
||||
|
||||
emitProgress(loaded: number, total: number) {
|
||||
this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent);
|
||||
}
|
||||
|
||||
respond(status: number, body: string) {
|
||||
this.status = status;
|
||||
this.responseText = body;
|
||||
this.onload?.();
|
||||
}
|
||||
|
||||
emitTimeout() {
|
||||
this.ontimeout?.();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type UploadProgress = {
|
||||
export type AdminApiOptions = {
|
||||
csrf?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const exactMessages: Record<string, string> = {
|
||||
@@ -30,6 +31,7 @@ const exactMessages: Record<string, string> = {
|
||||
};
|
||||
|
||||
const codeMessages: Record<string, string> = {
|
||||
INTERNAL_SERVER_ERROR: "服务端处理请求时发生异常,请稍后重试并检查服务日志",
|
||||
UNAUTHORIZED: "需要登录后继续操作",
|
||||
LOGIN_FAILED: "登录失败,请检查密码和验证码",
|
||||
LOGIN_LOCKED: "登录失败次数过多,请 5 分钟后重试",
|
||||
@@ -52,6 +54,9 @@ const codeMessages: Record<string, string> = {
|
||||
PACKAGE_TOO_LARGE: "发布包超过服务端上传上限",
|
||||
UPLOAD_STORAGE_FAILED: "服务端无法保存上传文件",
|
||||
MANIFEST_UPDATE_FAILED: "发布包已回滚,更新清单写入失败",
|
||||
PACKAGE_INDEX_FAILED: "发布包已回滚,数据库索引更新失败",
|
||||
PACKAGE_TYPE_UNSUPPORTED: "仅支持 EXE、MSIX、APPINSTALLER、MSI、ZIP 或 7Z 发布包",
|
||||
PACKAGE_NAME_INVALID: "发布包文件名不合法",
|
||||
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
|
||||
UPLOAD_INTERRUPTED: "上传连接已中断,请保持页面打开后重试",
|
||||
SOURCE_SAVE_FAILED: "接口源保存失败",
|
||||
@@ -74,7 +79,7 @@ export async function adminFetch<T>(target: string, init: RequestInit = {}, opti
|
||||
init.signal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
try {
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include", signal: controller.signal });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const data = await res.json().catch(() => ({})) as Record<string, unknown>;
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(adminErrorMessage(data, res.status));
|
||||
}
|
||||
@@ -93,29 +98,45 @@ export async function adminFetch<T>(target: string, init: RequestInit = {}, opti
|
||||
export function uploadAdminFile<T>(target: string, form: FormData, options: AdminApiOptions, onProgress: (progress: UploadProgress) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
const finish = (action: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
options.signal?.removeEventListener("abort", abortUpload);
|
||||
action();
|
||||
};
|
||||
const abortUpload = () => xhr.abort();
|
||||
xhr.open("POST", target);
|
||||
xhr.withCredentials = true;
|
||||
xhr.timeout = Math.max(1000, options.timeoutMs ?? 30 * 60 * 1000);
|
||||
if (options.csrf) xhr.setRequestHeader("X-CSRF-Token", options.csrf);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress({ loaded: event.loaded, total: event.total });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const data = parseJSONSafe(xhr.responseText, {});
|
||||
const data = parseJSONSafe<Record<string, unknown>>(xhr.responseText, {});
|
||||
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
|
||||
reject(new Error(adminErrorMessage(data, xhr.status)));
|
||||
finish(() => reject(new Error(adminErrorMessage(data, xhr.status))));
|
||||
return;
|
||||
}
|
||||
resolve(data as T);
|
||||
finish(() => resolve(data as T));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("网络异常,发布包上传失败"));
|
||||
xhr.onabort = () => reject(new Error("发布包上传已取消"));
|
||||
xhr.onerror = () => finish(() => reject(new Error("网络异常,发布包上传失败")));
|
||||
xhr.ontimeout = () => finish(() => reject(new Error("上传超时,服务端未在限定时间内响应")));
|
||||
xhr.onabort = () => finish(() => reject(new Error("发布包上传已取消")));
|
||||
if (options.signal?.aborted) {
|
||||
finish(() => reject(new Error("发布包上传已取消")));
|
||||
return;
|
||||
}
|
||||
options.signal?.addEventListener("abort", abortUpload, { once: true });
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
function adminErrorMessage(data: any, status: number) {
|
||||
const code = String(data?.error || "").trim();
|
||||
const detail = String(data?.message || "").trim();
|
||||
function adminErrorMessage(data: unknown, status: number) {
|
||||
const payload = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
||||
const code = String(payload.error || "").trim();
|
||||
const detail = String(payload.message || "").trim();
|
||||
if (code && codeMessages[code]) {
|
||||
return codeMessages[code];
|
||||
}
|
||||
@@ -131,9 +152,9 @@ export function toChineseError(value: string) {
|
||||
return raw || "操作失败";
|
||||
}
|
||||
|
||||
function parseJSONSafe(value: string, fallback: any) {
|
||||
function parseJSONSafe<T>(value: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(value || "{}");
|
||||
return JSON.parse(value || "{}") as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ export const defaultBranding: Branding = {
|
||||
feedbackEmail: "support@ymhut.cn",
|
||||
};
|
||||
|
||||
function valueOf(source: any, key: keyof Branding) {
|
||||
const value = source?.[key];
|
||||
function valueOf(source: unknown, key: keyof Branding) {
|
||||
const value = source && typeof source === "object" ? (source as Record<string, unknown>)[key] : undefined;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeBranding(source: any): Branding {
|
||||
export function normalizeBranding(source: unknown): Branding {
|
||||
const siteName = valueOf(source, "siteName") || defaultBranding.siteName;
|
||||
const siteIconUrl = valueOf(source, "siteIconUrl") || defaultBranding.siteIconUrl;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
type TrendPoint = { label?: string; latency?: number; sampleCount?: number };
|
||||
|
||||
const props = defineProps<{ points: TrendPoint[] }>();
|
||||
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const visible = ref(false);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const width = 760;
|
||||
const height = 250;
|
||||
const padding = { left: 54, right: 18, top: 18, bottom: 42 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
|
||||
const normalized = computed(() => props.points.map((item) => ({
|
||||
label: String(item.label || ""),
|
||||
latency: Math.max(0, Number(item.latency) || 0),
|
||||
sampleCount: Math.max(0, Number(item.sampleCount) || 0),
|
||||
})));
|
||||
const maxLatency = computed(() => Math.max(50, ...normalized.value.map((item) => item.latency)));
|
||||
const plotPoints = computed(() => normalized.value.map((item, index, rows) => {
|
||||
const x = padding.left + (rows.length <= 1 ? plotWidth / 2 : (index / (rows.length - 1)) * plotWidth);
|
||||
const y = padding.top + plotHeight - (item.latency / maxLatency.value) * plotHeight;
|
||||
return { ...item, x, y };
|
||||
}));
|
||||
const polyline = computed(() => plotPoints.value.map((item) => `${item.x.toFixed(1)},${item.y.toFixed(1)}`).join(" "));
|
||||
const yTicks = computed(() => [0, 0.25, 0.5, 0.75, 1].map((ratio) => ({
|
||||
y: padding.top + plotHeight - ratio * plotHeight,
|
||||
label: `${Math.round(maxLatency.value * ratio)}ms`,
|
||||
})));
|
||||
const xTicks = computed(() => {
|
||||
const rows = plotPoints.value;
|
||||
if (rows.length <= 5) return rows;
|
||||
const indices = new Set([0, Math.round((rows.length - 1) * 0.25), Math.round((rows.length - 1) * 0.5), Math.round((rows.length - 1) * 0.75), rows.length - 1]);
|
||||
return [...indices].map((index) => rows[index]).filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
visible.value = true;
|
||||
return;
|
||||
}
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
visible.value = true;
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
}, { rootMargin: "160px" });
|
||||
if (host.value) observer.observe(host.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="chart chart-host" role="img" aria-label="所有接口平均延迟趋势">
|
||||
<svg v-if="visible" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none" aria-hidden="true">
|
||||
<g class="trend-grid">
|
||||
<template v-for="tick in yTicks" :key="tick.label">
|
||||
<line :x1="padding.left" :x2="width - padding.right" :y1="tick.y" :y2="tick.y" />
|
||||
<text :x="padding.left - 8" :y="tick.y + 4" text-anchor="end">{{ tick.label }}</text>
|
||||
</template>
|
||||
</g>
|
||||
<polyline class="trend-line" :points="polyline" />
|
||||
<g class="trend-points">
|
||||
<circle v-for="point in plotPoints" :key="`${point.x}-${point.label}`" :cx="point.x" :cy="point.y" r="3.5"><title>{{ point.label }}:{{ point.latency }}ms,{{ point.sampleCount }} 个样本</title></circle>
|
||||
</g>
|
||||
<g class="trend-axis-labels">
|
||||
<text v-for="tick in xTicks" :key="`${tick.x}-${tick.label}`" :x="tick.x" :y="height - 14" text-anchor="middle">{{ tick.label }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
<span v-else class="chart-inline-loading">图表进入可视区域后加载</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __ADMIN_BUILD_ID__: string;
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -1,50 +1,64 @@
|
||||
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 { createAdminRouter } from "./router";
|
||||
import { adminResourceDiagnostic, adminResourceMarker, claimAdminResourceReload } from "./utils/resourceRecovery";
|
||||
import "./styles.css";
|
||||
|
||||
const resourceReloadKey = "ymhut.admin.resource-reload";
|
||||
const resourceReloadKey = `ymhut.admin.resource-reload:${__ADMIN_BUILD_ID__}`;
|
||||
|
||||
function isAdminResourceFailure(value: unknown) {
|
||||
const message = value instanceof Error ? value.message : String(value || "");
|
||||
return /failed to fetch dynamically imported module|loading chunk|module script|importing a module/i.test(message);
|
||||
}
|
||||
|
||||
function showResourceFailure() {
|
||||
if (document.getElementById("admin-resource-failure")) return;
|
||||
const notice = document.createElement("div");
|
||||
notice.id = "admin-resource-failure";
|
||||
notice.setAttribute("role", "alert");
|
||||
notice.textContent = "后台资源加载失败,请刷新页面后重试。";
|
||||
Object.assign(notice.style, {
|
||||
position: "fixed",
|
||||
inset: "16px 16px auto 16px",
|
||||
zIndex: "2147483647",
|
||||
padding: "12px 16px",
|
||||
border: "1px solid #dc2626",
|
||||
borderRadius: "6px",
|
||||
color: "#7f1d1d",
|
||||
background: "#fef2f2",
|
||||
fontFamily: "Segoe UI, sans-serif",
|
||||
fontSize: "14px",
|
||||
});
|
||||
document.body.appendChild(notice);
|
||||
function diagnosticNumber() {
|
||||
return adminResourceDiagnostic(__ADMIN_BUILD_ID__, location);
|
||||
}
|
||||
|
||||
function recoverAdminResources() {
|
||||
const canonical = new URL(location.href);
|
||||
canonical.searchParams.delete("_admin_reload");
|
||||
const marker = `${canonical.pathname}${canonical.search}`;
|
||||
if (sessionStorage.getItem(resourceReloadKey) === marker) {
|
||||
showResourceFailure();
|
||||
function showResourceFailure(reason?: unknown) {
|
||||
if (document.getElementById("admin-resource-failure")) return;
|
||||
const screen = document.createElement("main");
|
||||
screen.id = "admin-resource-failure";
|
||||
screen.setAttribute("role", "alert");
|
||||
screen.innerHTML = `
|
||||
<section>
|
||||
<p class="resource-failure-kicker">管理后台恢复模式</p>
|
||||
<h1>页面资源未能完整加载</h1>
|
||||
<p>后台已阻止继续使用不一致的页面资源。请重试;若问题仍存在,请使用下方诊断编号检查服务端资源清单。</p>
|
||||
<dl><div><dt>构建</dt><dd></dd></div><div><dt>诊断编号</dt><dd></dd></div></dl>
|
||||
<div class="resource-failure-actions"><button type="button">重新加载</button><a href="/admin/dashboard">返回概览</a></div>
|
||||
</section>`;
|
||||
const details = screen.querySelectorAll("dd");
|
||||
details[0].textContent = __ADMIN_BUILD_ID__;
|
||||
details[1].textContent = diagnosticNumber();
|
||||
screen.querySelector("button")?.addEventListener("click", () => {
|
||||
sessionStorage.removeItem(resourceReloadKey);
|
||||
location.reload();
|
||||
});
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
#admin-resource-failure{position:fixed;inset:0;z-index:2147483647;display:grid;place-items:center;padding:24px;background:#f4f7f6;color:#263331;font:14px/1.6 "Segoe UI",sans-serif}
|
||||
#admin-resource-failure section{width:min(620px,100%);padding:28px;background:#fff;border:1px solid #d7e0de;border-radius:8px;box-shadow:0 12px 32px rgba(31,45,42,.08)}
|
||||
#admin-resource-failure .resource-failure-kicker{margin:0 0 6px;color:#0f766e;font-weight:700}#admin-resource-failure h1{margin:0 0 10px;font-size:24px;letter-spacing:0}
|
||||
#admin-resource-failure dl{margin:20px 0;display:grid;gap:8px}#admin-resource-failure dl div{display:grid;grid-template-columns:100px 1fr;padding:8px 0;border-bottom:1px solid #e7eceb}#admin-resource-failure dt{color:#667572}#admin-resource-failure dd{margin:0;font-family:Consolas,monospace;overflow-wrap:anywhere}
|
||||
#admin-resource-failure .resource-failure-actions{display:flex;gap:10px;flex-wrap:wrap}#admin-resource-failure button,#admin-resource-failure a{min-height:38px;padding:8px 14px;border-radius:6px;font:inherit;font-weight:650;text-decoration:none;cursor:pointer}
|
||||
#admin-resource-failure button{border:1px solid #0f766e;background:#0f766e;color:#fff}#admin-resource-failure a{border:1px solid #bac7c4;background:#fff;color:#263331}`;
|
||||
document.head.appendChild(style);
|
||||
document.body.replaceChildren(screen);
|
||||
console.error("Admin resource loading failed", { buildId: __ADMIN_BUILD_ID__, diagnostic: diagnosticNumber(), reason });
|
||||
}
|
||||
|
||||
function recoverAdminResources(reason?: unknown) {
|
||||
const marker = adminResourceMarker(__ADMIN_BUILD_ID__, location);
|
||||
if (!claimAdminResourceReload(sessionStorage, resourceReloadKey, marker)) {
|
||||
showResourceFailure(reason);
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(resourceReloadKey, marker);
|
||||
const next = new URL(location.href);
|
||||
next.searchParams.set("_admin_reload", Date.now().toString());
|
||||
location.replace(next);
|
||||
@@ -58,39 +72,17 @@ window.addEventListener("error", (event) => {
|
||||
? target.href
|
||||
: "";
|
||||
if (resource.includes("/admin/assets/") || isAdminResourceFailure(event.error || event.message)) {
|
||||
recoverAdminResources();
|
||||
recoverAdminResources(event.error || event.message);
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
if (isAdminResourceFailure(event.reason)) recoverAdminResources();
|
||||
if (isAdminResourceFailure(event.reason)) recoverAdminResources(event.reason);
|
||||
});
|
||||
|
||||
const RoutePlaceholder = { template: "<span />" };
|
||||
|
||||
const routes = [
|
||||
"/admin/login",
|
||||
"/admin/dashboard",
|
||||
"/admin/feedbacks",
|
||||
"/admin/releases",
|
||||
"/admin/legacy/update-info",
|
||||
"/admin/legacy/media-types",
|
||||
"/admin/sources",
|
||||
"/admin/endpoints",
|
||||
"/admin/system",
|
||||
].map((path) => ({ path, component: RoutePlaceholder }));
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
...routes,
|
||||
{ path: "/admin/database", redirect: { path: "/admin/system", query: { tab: "database" } } },
|
||||
{ path: "/admin/health", redirect: { path: "/admin/system", query: { tab: "health" } } },
|
||||
{ path: "/admin/settings", redirect: { path: "/admin/system", query: { tab: "security" } } },
|
||||
{ path: "/admin/audit", redirect: { path: "/admin/system", query: { tab: "audit" } } },
|
||||
{ path: "/admin", redirect: "/admin/dashboard" },
|
||||
{ path: "/admin/:pathMatch(.*)*", redirect: "/admin/dashboard" },
|
||||
],
|
||||
const router = createAdminRouter();
|
||||
router.onError((error) => {
|
||||
if (isAdminResourceFailure(error)) recoverAdminResources(error);
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
@@ -115,5 +107,3 @@ createApp(App)
|
||||
.use(ToastService)
|
||||
.use(ConfirmationService)
|
||||
.mount("#app");
|
||||
|
||||
window.setTimeout(() => sessionStorage.removeItem(resourceReloadKey), 30000);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { adminNavigationGroups, createAdminRouter } from "./router";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "scrollTo", { configurable: true, value: vi.fn() });
|
||||
});
|
||||
|
||||
describe("admin router", () => {
|
||||
it("keeps audit as a real page instead of redirecting it", () => {
|
||||
const router = createAdminRouter();
|
||||
const match = router.resolve("/admin/audit");
|
||||
|
||||
expect(match.path).toBe("/admin/audit");
|
||||
expect(match.matched.at(-1)?.redirect).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/admin/database", "/admin/system?tab=database"],
|
||||
["/admin/health", "/admin/system?tab=health"],
|
||||
["/admin/settings", "/admin/system?tab=security"],
|
||||
])("redirects the legacy route %s", async (legacyPath, expectedPath) => {
|
||||
const router = createAdminRouter();
|
||||
|
||||
await router.push(legacyPath);
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it("uses the standardized five navigation groups", () => {
|
||||
expect(adminNavigationGroups.map((group) => group.label)).toEqual([
|
||||
"概览",
|
||||
"反馈",
|
||||
"发布与兼容",
|
||||
"客户端配置",
|
||||
"系统运维",
|
||||
]);
|
||||
expect(adminNavigationGroups.every((group) => group.items.length > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
Database,
|
||||
FileJson,
|
||||
LayoutDashboard,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
ShieldCheck,
|
||||
type LucideIcon,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
export type AdminNavigationItem = {
|
||||
path: string;
|
||||
label: string;
|
||||
description: string;
|
||||
group: "概览" | "反馈" | "发布与兼容" | "客户端配置" | "系统运维";
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export const adminNavigation: AdminNavigationItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", group: "概览", icon: LayoutDashboard },
|
||||
{ path: "/admin/feedbacks", label: "反馈工单", description: "反馈受理、处理流转与回复记录", group: "反馈", icon: MessageSquareText },
|
||||
{ path: "/admin/releases", label: "发布与公告", description: "发布包、版本公告和兼容日志", group: "发布与兼容", icon: ArrowDownToLine },
|
||||
{ path: "/admin/legacy/update-info", label: "更新配置", description: "维护 update-info.json", group: "发布与兼容", icon: FileJson },
|
||||
{ path: "/admin/legacy/media-types", label: "媒体源配置", description: "维护 media-types.json", group: "发布与兼容", icon: ClipboardList },
|
||||
{ path: "/admin/sources", label: "来源目录", description: "媒体与数据源目录和健康检测", group: "客户端配置", icon: Network },
|
||||
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", group: "客户端配置", icon: Code2 },
|
||||
{ path: "/admin/audit", label: "审计日志", description: "操作审计、登录记录与安全事件", group: "系统运维", icon: ShieldCheck },
|
||||
{ path: "/admin/system", label: "系统运维", description: "数据库、同步、安全与服务健康", group: "系统运维", icon: Database },
|
||||
];
|
||||
|
||||
export const adminNavigationGroups = ["概览", "反馈", "发布与兼容", "客户端配置", "系统运维"].map((label) => ({
|
||||
label,
|
||||
items: adminNavigation.filter((item) => item.group === label),
|
||||
}));
|
||||
|
||||
const viewLoaders = {
|
||||
login: () => import("./views/LoginView.vue"),
|
||||
dashboard: () => import("./views/DashboardView.vue"),
|
||||
feedbacks: () => import("./views/FeedbacksView.vue"),
|
||||
releases: () => import("./views/ReleasesView.vue"),
|
||||
legacy: () => import("./views/LegacyJsonView.vue"),
|
||||
sources: () => import("./views/SourcesView.vue"),
|
||||
endpoints: () => import("./views/EndpointsView.vue"),
|
||||
audit: () => import("./views/AuditLogView.vue"),
|
||||
system: () => import("./views/SystemView.vue"),
|
||||
} as const;
|
||||
|
||||
const prefetchByPath: Record<string, () => Promise<unknown>> = {
|
||||
"/admin/dashboard": viewLoaders.dashboard,
|
||||
"/admin/feedbacks": viewLoaders.feedbacks,
|
||||
"/admin/releases": viewLoaders.releases,
|
||||
"/admin/legacy/update-info": viewLoaders.legacy,
|
||||
"/admin/legacy/media-types": viewLoaders.legacy,
|
||||
"/admin/sources": viewLoaders.sources,
|
||||
"/admin/endpoints": viewLoaders.endpoints,
|
||||
"/admin/audit": viewLoaders.audit,
|
||||
"/admin/system": viewLoaders.system,
|
||||
};
|
||||
|
||||
export function prefetchAdminRoute(path: string) {
|
||||
return prefetchByPath[path]?.();
|
||||
}
|
||||
|
||||
export async function prefetchPriorityAdminRoutes() {
|
||||
await Promise.allSettled([viewLoaders.feedbacks(), viewLoaders.releases()]);
|
||||
}
|
||||
|
||||
const pageRoutes: RouteRecordRaw[] = [
|
||||
{ path: "/admin/login", component: viewLoaders.login, meta: { public: true, title: "后台登录" } },
|
||||
{ path: "/admin/dashboard", component: viewLoaders.dashboard },
|
||||
{ path: "/admin/feedbacks", component: viewLoaders.feedbacks },
|
||||
{ path: "/admin/releases", component: viewLoaders.releases },
|
||||
{ path: "/admin/legacy/update-info", component: viewLoaders.legacy },
|
||||
{ path: "/admin/legacy/media-types", component: viewLoaders.legacy },
|
||||
{ path: "/admin/sources", component: viewLoaders.sources },
|
||||
{ path: "/admin/endpoints", component: viewLoaders.endpoints },
|
||||
{ path: "/admin/audit", component: viewLoaders.audit },
|
||||
{ path: "/admin/system", component: viewLoaders.system },
|
||||
];
|
||||
|
||||
export function createAdminRouter() {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
...pageRoutes,
|
||||
{ path: "/admin/database", redirect: { path: "/admin/system", query: { tab: "database" } } },
|
||||
{ path: "/admin/health", redirect: { path: "/admin/system", query: { tab: "health" } } },
|
||||
{ path: "/admin/settings", redirect: { path: "/admin/system", query: { tab: "security" } } },
|
||||
{ path: "/admin", redirect: "/admin/dashboard" },
|
||||
{ path: "/admin/:pathMatch(.*)*", redirect: "/admin/dashboard" },
|
||||
],
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import type { AuthBootstrap, CaptchaChallenge } from "../types/admin";
|
||||
|
||||
export function createAuthStore() {
|
||||
const csrf = ref(sessionStorage.getItem("ymhut.csrf") || "");
|
||||
const captcha = ref<any | null>(null);
|
||||
const bootstrap = ref<any | null>(null);
|
||||
const captcha = ref<CaptchaChallenge | null>(null);
|
||||
const bootstrap = ref<AuthBootstrap | null>(null);
|
||||
const loginForm = reactive({ username: "", password: "", captcha: "" });
|
||||
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
|
||||
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import { ref } from "vue";
|
||||
import type { DashboardData, SourceCheckJob } from "../types/admin";
|
||||
|
||||
export function createDashboardStore() {
|
||||
const dashboard = ref<any>({});
|
||||
const sourceCheckJobs = ref<any[]>([]);
|
||||
const dashboard = ref<DashboardData>({});
|
||||
const sourceCheckJobs = ref<SourceCheckJob[]>([]);
|
||||
|
||||
return { dashboard, sourceCheckJobs };
|
||||
}
|
||||
|
||||
export function normalizeDashboardData(value: unknown): DashboardData {
|
||||
const input = value && typeof value === "object" ? value as Record<string, unknown> : {};
|
||||
const record = (candidate: unknown) => candidate && typeof candidate === "object" && !Array.isArray(candidate)
|
||||
? candidate as Record<string, number | string | undefined>
|
||||
: {};
|
||||
const array = <T>(candidate: unknown) => Array.isArray(candidate) ? candidate as T[] : [];
|
||||
return {
|
||||
...input,
|
||||
kpis: record(input.kpis),
|
||||
sourceHealth: record(input.sourceHealth) as Record<string, number | undefined>,
|
||||
feedbackStatus: record(input.feedbackStatus) as Record<string, number | undefined>,
|
||||
heartbeats: array(input.heartbeats),
|
||||
clientCalls: array(input.clientCalls),
|
||||
averageLatency: array(input.averageLatency ?? input.average_latency),
|
||||
sourceRows: array(input.sourceRows),
|
||||
sourceCheckJobs: array(input.sourceCheckJobs),
|
||||
warnings: array(input.warnings).map(String),
|
||||
generatedAt: typeof input.generatedAt === "string" ? input.generatedAt : "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import type { FeedbackItem, FeedbackPage } from "../types/admin";
|
||||
|
||||
export function createFeedbackStore() {
|
||||
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selected = ref<any | null>(null);
|
||||
const page = ref<FeedbackPage>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selected = ref<FeedbackItem | null>(null);
|
||||
const filters = reactive({ q: "", status: "", priority: "", category: "", assignee: "", page: 1, perPage: 20 });
|
||||
const update = reactive<{ status: string; priority: string; statusDetail: string; publicReply: string; assignee: string; tags: string[] }>({
|
||||
status: "",
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import type { JsonObject } from "../types/admin";
|
||||
|
||||
export type LegacyName = "update-info" | "media-types";
|
||||
type LegacyDocument = { raw: string; parsed: JsonObject; revisions?: JsonObject[] };
|
||||
type LegacyDraft = { raw: string; note: string; preview: JsonObject | null; tab: "form" | "raw" | "preview" | "history"; form: JsonObject };
|
||||
|
||||
export function createLegacyStore() {
|
||||
const sync = ref<any>(null);
|
||||
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
|
||||
const sync = ref<JsonObject | null>(null);
|
||||
const documents = reactive<Record<LegacyName, LegacyDocument | null>>({ "update-info": null, "media-types": null });
|
||||
const modal = reactive({
|
||||
open: false,
|
||||
type: "",
|
||||
categoryIndex: -1,
|
||||
itemIndex: -1,
|
||||
draft: {} as any,
|
||||
draft: {} as JsonObject,
|
||||
});
|
||||
const activeMediaCategoryIndex = ref(0);
|
||||
const drafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
|
||||
const drafts = reactive<Record<LegacyName, LegacyDraft>>({
|
||||
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import type { JsonObject, ReleaseManifest, ReleaseNotice } from "../types/admin";
|
||||
|
||||
export function createReleaseStore() {
|
||||
const releases = ref<any>(null);
|
||||
const notices = ref<any[]>([]);
|
||||
const selectedNotice = ref<any | null>(null);
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
const releases = ref<ReleaseManifest | null>(null);
|
||||
const notices = ref<ReleaseNotice[]>([]);
|
||||
const selectedNotice = ref<ReleaseNotice | null>(null);
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as JsonObject | null });
|
||||
const uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import type { SourceCatalog, SourceEndpoint } from "../types/admin";
|
||||
|
||||
export function createSourceStore() {
|
||||
const sources = ref<any>({ categories: [] });
|
||||
const endpoints = ref<any[]>([]);
|
||||
const sources = ref<SourceCatalog>({ categories: [] });
|
||||
const endpoints = ref<SourceEndpoint[]>([]);
|
||||
const draft = reactive({
|
||||
sourceId: "",
|
||||
categoryId: "custom",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createAuthStore } from "./auth";
|
||||
import { createFeedbackStore } from "./feedback";
|
||||
import { createReleaseStore } from "./releases";
|
||||
import { createSourceStore } from "./sources";
|
||||
import { createSystemStore } from "./system";
|
||||
import { normalizeDashboardData } from "./dashboard";
|
||||
|
||||
describe("admin feature stores", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
|
||||
it("starts authentication without public default credentials", () => {
|
||||
const store = createAuthStore();
|
||||
|
||||
expect(store.loginForm).toEqual({ username: "", password: "", captcha: "" });
|
||||
expect(store.bootstrap.value).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps feature store instances isolated", () => {
|
||||
const first = createSourceStore();
|
||||
const second = createSourceStore();
|
||||
|
||||
first.draft.name = "changed";
|
||||
|
||||
expect(second.draft.name).toBe("");
|
||||
});
|
||||
|
||||
it("provides stable defaults for paged, upload and operations state", () => {
|
||||
const feedback = createFeedbackStore();
|
||||
const releases = createReleaseStore();
|
||||
const system = createSystemStore();
|
||||
|
||||
expect(feedback.page.value).toMatchObject({ items: [], page: 1, perPage: 20 });
|
||||
expect(releases.uploadDraft).toMatchObject({ file: null, uploading: false, progress: 0 });
|
||||
expect(system.auditPage).toMatchObject({ items: [], page: 1, perPage: 35 });
|
||||
expect(system.databaseSyncOutput.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes partial dashboard responses without losing compatible fields", () => {
|
||||
const normalized = normalizeDashboardData({
|
||||
kpis: { feedbackTotal: 4 },
|
||||
average_latency: [{ label: "10:00", averageLatency: 32 }],
|
||||
sourceRows: null,
|
||||
warnings: "not-an-array",
|
||||
});
|
||||
|
||||
expect(normalized.kpis?.feedbackTotal).toBe(4);
|
||||
expect(normalized.averageLatency).toHaveLength(1);
|
||||
expect(normalized.sourceRows).toEqual([]);
|
||||
expect(normalized.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,44 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import { defaultBranding } from "../branding";
|
||||
import type {
|
||||
AuditLog,
|
||||
DatabaseConfig,
|
||||
DatabaseStatus,
|
||||
DatabaseSyncJob,
|
||||
JsonObject,
|
||||
MailConfig,
|
||||
MigrationStatus,
|
||||
SystemLog,
|
||||
} from "../types/admin";
|
||||
|
||||
export function createSystemStore() {
|
||||
const database = ref<any>(null);
|
||||
const databaseConfig = ref<any>(null);
|
||||
const databaseLastSync = ref<any>(null);
|
||||
const databaseSyncJob = ref<any>(null);
|
||||
const database = ref<DatabaseStatus | null>(null);
|
||||
const databaseConfig = ref<DatabaseConfig | null>(null);
|
||||
const databaseLastSync = ref<DatabaseSyncJob | null>(null);
|
||||
const databaseSyncJob = ref<DatabaseSyncJob | null>(null);
|
||||
const databaseSyncOutput = ref<string[]>([]);
|
||||
const healthSnapshot = ref<any>(null);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const healthSnapshot = ref<JsonObject | null>(null);
|
||||
const auditLogs = ref<AuditLog[]>([]);
|
||||
const auditPage = reactive({
|
||||
items: [] as any[],
|
||||
items: [] as AuditLog[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
perPage: 35,
|
||||
q: "",
|
||||
type: "",
|
||||
target: "",
|
||||
selected: null as any | null,
|
||||
selected: null as AuditLog | null,
|
||||
});
|
||||
const systemLogPage = reactive({
|
||||
items: [] as any[],
|
||||
items: [] as SystemLog[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
perPage: 35,
|
||||
q: "",
|
||||
category: "",
|
||||
selected: null as any | null,
|
||||
selected: null as SystemLog | null,
|
||||
});
|
||||
const migrationStatus = ref<any>(null);
|
||||
const migrationStatus = ref<MigrationStatus | null>(null);
|
||||
const branding = reactive({
|
||||
...defaultBranding,
|
||||
});
|
||||
@@ -43,7 +53,7 @@ export function createSystemStore() {
|
||||
mysqlDsn: "",
|
||||
});
|
||||
const databaseConfigCollapsed = ref(true);
|
||||
const mailConfig = reactive({
|
||||
const mailConfig = reactive<MailConfig>({
|
||||
host: "",
|
||||
port: 465,
|
||||
secure: "ssl",
|
||||
|
||||
@@ -43,7 +43,8 @@ h3 { margin-bottom: 8px; font-size: 15px; }
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: #f4f6f5;
|
||||
box-shadow: inset 0 4px 0 var(--primary);
|
||||
}
|
||||
@@ -52,8 +53,8 @@ h3 { margin-bottom: 8px; font-size: 15px; }
|
||||
width: min(460px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
gap: 14px;
|
||||
padding: 22px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
@@ -107,7 +108,7 @@ input:focus, textarea:focus, select:focus {
|
||||
font-weight: 800;
|
||||
transition: transform 0.18s var(--ease), background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); border-color: var(--line-strong); background: #f9fafb; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07); }
|
||||
.btn:hover { border-color: var(--line-strong); background: #f9fafb; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07); }
|
||||
.btn.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
.btn.ghost { background: transparent; }
|
||||
@@ -227,10 +228,36 @@ input:focus, textarea:focus, select:focus {
|
||||
.nav-group button:hover, .logout:hover { background: #edf6f3; color: var(--primary-dark); box-shadow: inset 3px 0 0 var(--primary); overflow: hidden; }
|
||||
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
|
||||
.logout { color: #7f1d1d; }
|
||||
.mobile-nav-trigger, .mobile-nav-close, .mobile-nav-backdrop { display: none; }
|
||||
.page-heading { min-width: 0; }
|
||||
.breadcrumbs { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; color: var(--muted); font-size: 12px; }
|
||||
.breadcrumbs i { color: #a7b2af; font-style: normal; }
|
||||
.breadcrumbs strong { color: var(--primary-dark); }
|
||||
|
||||
.workspace { min-width: 0; max-width: 100%; overflow-x: hidden; padding: 24px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.topbar, .section-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
|
||||
.topbar { min-height: 72px; }
|
||||
|
||||
.security-banner {
|
||||
min-height: 44px;
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: #7c2d12;
|
||||
background: #fff7ed;
|
||||
border-bottom: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
.security-banner > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.security-banner .btn { flex: 0 0 auto; }
|
||||
.section-head h2 { margin: 0; }
|
||||
.section-head a { color: var(--primary); font-weight: 800; text-decoration: none; }
|
||||
|
||||
@@ -249,13 +276,10 @@ input:focus, textarea:focus, select:focus {
|
||||
background: var(--panel-soft);
|
||||
padding: 14px;
|
||||
}
|
||||
.metric, .panel, .revision-list button, .nested-card {
|
||||
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.metric:hover, .panel:hover, .revision-list button:hover, .nested-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
.metric, .revision-list button, .nested-card {
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
.revision-list button:hover, .nested-card:hover { box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06); }
|
||||
.metric { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.metric span, .metric small { color: var(--muted); }
|
||||
.metric strong { font-size: 26px; overflow-wrap: anywhere; }
|
||||
@@ -264,6 +288,13 @@ input:focus, textarea:focus, select:focus {
|
||||
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
|
||||
.chart-panel-relative { position: relative; }
|
||||
.chart { min-height: 260px; width: 100%; flex: 1; }
|
||||
.chart-host { position: relative; height: 260px; flex: 0 0 260px; }
|
||||
.chart-host svg { width: 100%; height: 100%; overflow: visible; }
|
||||
.trend-grid line { stroke: #e1e7e5; stroke-width: 1; }
|
||||
.trend-grid text, .trend-axis-labels text { fill: var(--muted); font-size: 11px; }
|
||||
.trend-line { fill: none; stroke: var(--primary); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||
.trend-points circle { fill: #fff; stroke: var(--primary-dark); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.chart-inline-loading { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); }
|
||||
.chart-empty {
|
||||
position: absolute;
|
||||
inset: 56px 16px 16px;
|
||||
@@ -278,6 +309,30 @@ input:focus, textarea:focus, select:focus {
|
||||
pointer-events: none;
|
||||
}
|
||||
.chart-empty strong { color: var(--ink); }
|
||||
.chart-state {
|
||||
min-height: 260px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.chart-error { color: var(--bad); background: var(--bad-bg); border-color: #f0b8b1; }
|
||||
.chart-loading { color: var(--primary-dark); }
|
||||
.availability-panel { min-height: 240px; display: flex; flex-direction: column; justify-content: center; gap: 18px; }
|
||||
.availability-panel .section-head strong { font-size: 30px; color: var(--ink); }
|
||||
.bullet-track { height: 14px; overflow: hidden; border-radius: 6px; background: #dce3e0; }
|
||||
.bullet-track span { display: block; height: 100%; border-radius: 6px; background: #34413e; transition: width 0.18s ease; }
|
||||
.bullet-scale { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; }
|
||||
.data-fallback { padding-block: 10px; }
|
||||
.distribution-list { min-height: 240px; display: flex; flex-direction: column; justify-content: center; gap: 14px; }
|
||||
.distribution-row { display: grid; grid-template-columns: minmax(72px, 112px) minmax(80px, 1fr) 36px; align-items: center; gap: 10px; }
|
||||
.distribution-row > span { color: var(--muted); font-size: 13px; }
|
||||
.distribution-row > div { height: 10px; overflow: hidden; border-radius: 5px; background: #e5eae8; }
|
||||
.distribution-row i { display: block; height: 100%; min-width: 2px; border-radius: 5px; background: var(--primary); }
|
||||
.distribution-row strong { text-align: right; }
|
||||
.health-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -615,14 +670,15 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.form-grid .wide, label.wide { grid-column: 1 / -1; }
|
||||
.mini-editor { min-height: 160px; }
|
||||
.nested-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(248, 250, 252, 0.78);
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 14px 0 0;
|
||||
}
|
||||
.nested-card.inner {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
.upload-card {
|
||||
background: #fff;
|
||||
@@ -670,9 +726,47 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.security-banner {
|
||||
align-items: flex-start;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.security-banner > div { align-items: flex-start; }
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; max-width: none; overflow: visible; }
|
||||
.nav-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 950;
|
||||
width: min(300px, calc(100vw - 48px));
|
||||
max-width: none;
|
||||
height: 100dvh;
|
||||
overflow: hidden auto;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s var(--ease);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.nav-groups { display: flex; }
|
||||
.mobile-nav-trigger, .mobile-nav-close {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
.mobile-nav-close { margin-left: auto; }
|
||||
.mobile-nav-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 940;
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
}
|
||||
.mobile-nav-backdrop.open { display: block; }
|
||||
.workspace { padding: 16px; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.metric-grid, .two-col { grid-template-columns: 1fr; }
|
||||
@@ -682,6 +776,12 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.panel { overflow-x: auto; max-width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
.login-shell { place-items: start center; }
|
||||
.login-panel { gap: 10px; padding: 18px 22px; }
|
||||
.login-panel .form-stack { gap: 10px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { Branding } from "../branding";
|
||||
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type CaptchaChallenge = { captchaId: string; image: string };
|
||||
export type AuthBootstrap = { ok?: boolean; isDefaultPassword?: boolean };
|
||||
|
||||
export type DashboardHeartbeat = {
|
||||
checkedAt?: string;
|
||||
checked_at?: string;
|
||||
latencyMs?: number;
|
||||
latency_ms?: number;
|
||||
name?: string;
|
||||
sourceId?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type DashboardLatency = {
|
||||
label?: string;
|
||||
checkedAt?: string;
|
||||
checked_at?: string;
|
||||
averageLatency?: number;
|
||||
avgLatencyMs?: number;
|
||||
average_latency?: number;
|
||||
latencyMs?: number;
|
||||
sampleCount?: number;
|
||||
sample_count?: number;
|
||||
};
|
||||
|
||||
export type DashboardData = {
|
||||
kpis?: Record<string, number | string | undefined>;
|
||||
sourceHealth?: Record<string, number | undefined>;
|
||||
feedbackStatus?: Record<string, number | undefined>;
|
||||
heartbeats?: DashboardHeartbeat[];
|
||||
clientCalls?: JsonObject[];
|
||||
averageLatency?: DashboardLatency[];
|
||||
average_latency?: DashboardLatency[];
|
||||
sourceRows?: SourceEndpoint[];
|
||||
sourceCheckJobs?: SourceCheckJob[];
|
||||
generatedAt?: string;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
export type SourceCheckJob = JsonObject & {
|
||||
id?: string;
|
||||
jobId?: string;
|
||||
status?: string;
|
||||
checked?: number;
|
||||
total?: number;
|
||||
stats?: Record<string, number>;
|
||||
startedAt?: string;
|
||||
};
|
||||
|
||||
export type FeedbackItem = JsonObject & {
|
||||
code: string;
|
||||
title?: string;
|
||||
summaryText?: string;
|
||||
body?: string;
|
||||
contact?: string;
|
||||
sourceChannel?: string;
|
||||
mailSent?: boolean;
|
||||
createdAt?: string;
|
||||
lastActivityAt?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
category?: string;
|
||||
assignee?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type FeedbackDetail = FeedbackItem & {
|
||||
comments?: Array<{ id: number; author: string; body: string; internal: boolean; createdAt: string }>;
|
||||
events?: AuditLog[];
|
||||
legacyEvents?: Array<JsonObject & { id?: number; actor?: string; eventType?: string; message?: string; fromValue?: string; toValue?: string; createdAt?: string }>;
|
||||
mailRecords?: Array<JsonObject & { id?: number; status?: string; toAddress?: string; subject?: string; errorMessage?: string }>;
|
||||
};
|
||||
|
||||
export type FeedbackPage = { items: FeedbackItem[]; total: number; page: number; perPage: number };
|
||||
|
||||
export type ReleasePackage = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
url: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
required: boolean;
|
||||
enabled: boolean;
|
||||
fileName: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ReleaseManifest = JsonObject & { packages?: ReleasePackage[] };
|
||||
export type ReleaseNotice = JsonObject & {
|
||||
version: string;
|
||||
title?: string;
|
||||
channel?: string;
|
||||
raw?: string;
|
||||
parsed?: JsonObject;
|
||||
};
|
||||
|
||||
export type SourceHealth = JsonObject & {
|
||||
status?: string;
|
||||
latencyMs?: number;
|
||||
checkedAt?: string;
|
||||
error?: string;
|
||||
meta?: JsonObject;
|
||||
};
|
||||
|
||||
export type SourceEndpoint = JsonObject & {
|
||||
id?: string;
|
||||
sourceId?: string;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
clientVisible?: boolean;
|
||||
latencyMs?: number;
|
||||
checkedAt?: string;
|
||||
status?: string;
|
||||
health?: SourceHealth;
|
||||
healthError?: string;
|
||||
};
|
||||
|
||||
export type SourceCategory = JsonObject & { id?: string; name?: string; subcategories?: SourceEndpoint[] };
|
||||
export type SourceCatalog = { categories: SourceCategory[] };
|
||||
|
||||
export type DatabaseSyncJob = JsonObject & {
|
||||
id?: number;
|
||||
status?: string;
|
||||
direction?: string;
|
||||
output?: string[];
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
export type DatabaseStatus = JsonObject & {
|
||||
activeProvider?: string;
|
||||
configProvider?: string;
|
||||
schemaVersion?: string;
|
||||
sqliteReady?: boolean;
|
||||
remoteReady?: boolean;
|
||||
failoverActive?: boolean;
|
||||
currentSyncJob?: DatabaseSyncJob;
|
||||
};
|
||||
|
||||
export type DatabaseConfig = JsonObject & { provider?: string; hasPassword?: boolean };
|
||||
export type MigrationStatus = JsonObject & {
|
||||
strategy?: string;
|
||||
databaseCovers?: string[];
|
||||
fileAssets?: JsonObject[];
|
||||
};
|
||||
|
||||
export type AuditLog = JsonObject & {
|
||||
id?: number;
|
||||
type?: string;
|
||||
target?: string;
|
||||
actor?: string;
|
||||
message?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type SystemLog = JsonObject & {
|
||||
id?: number;
|
||||
category?: string;
|
||||
level?: string;
|
||||
message?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type MailConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: string;
|
||||
username: string;
|
||||
password: string;
|
||||
fromAddress: string;
|
||||
fromName: string;
|
||||
developerAddress: string;
|
||||
timeoutSeconds: number;
|
||||
hasPassword: boolean;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
export type AdminCommand = (...args: unknown[]) => unknown;
|
||||
|
||||
export type AuditPageState = {
|
||||
items: AuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
q: string;
|
||||
type: string;
|
||||
target: string;
|
||||
selected?: AuditLog | null;
|
||||
};
|
||||
|
||||
export type SystemLogPageState = {
|
||||
items: SystemLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
q: string;
|
||||
category: string;
|
||||
selected?: SystemLog | null;
|
||||
};
|
||||
|
||||
export interface LoginViewContext {
|
||||
branding: Branding;
|
||||
captcha: CaptchaChallenge | null;
|
||||
captchaPending: boolean;
|
||||
login: AdminCommand;
|
||||
loginForm: { username: string; password: string; captcha: string };
|
||||
loginPending: boolean;
|
||||
refreshCaptcha: AdminCommand;
|
||||
}
|
||||
|
||||
export interface DashboardViewContext {
|
||||
autoRefreshPaused: boolean;
|
||||
averageLatencyRows: Array<DashboardLatency & { latency?: number }>;
|
||||
checkSources: AdminCommand;
|
||||
clientCalls: JsonObject[];
|
||||
dashboardError: string;
|
||||
dashboardPending: boolean;
|
||||
dashboardWarnings: string[];
|
||||
feedbackDistributionRows: Array<{ name: string; value: number; percent: number }>;
|
||||
formatDateTime: AdminCommand;
|
||||
hasFeedbackStatusData: boolean;
|
||||
hasSourceHealthData: boolean;
|
||||
healthDistributionRows: Array<{ key: string; label: string; color: string; value: number; percent: number }>;
|
||||
healthyEndpointCount: number;
|
||||
isAverageLatencyChartEmpty: boolean;
|
||||
kpis: Record<string, number | string | undefined>;
|
||||
labelStatus: AdminCommand;
|
||||
latestNotice: ReleaseNotice | null;
|
||||
loadSystemLogs: AdminCommand;
|
||||
sourceAvailability: number;
|
||||
sourceAverageLatency: number;
|
||||
sourceCheckJobs: SourceCheckJob[];
|
||||
sourceLastCheckedAt: string;
|
||||
sourceMaxLatency: number;
|
||||
sourceRows: SourceEndpoint[];
|
||||
statusTone: AdminCommand;
|
||||
systemLogPage: SystemLogPageState;
|
||||
toggleAutoRefresh: AdminCommand;
|
||||
visibleEndpointCount: number;
|
||||
}
|
||||
|
||||
export interface FeedbackViewContext {
|
||||
addFeedbackComment: AdminCommand;
|
||||
addFeedbackTag: AdminCommand;
|
||||
bulkUpdateFeedbacks: AdminCommand;
|
||||
commentDraft: { body: string; internal: boolean };
|
||||
feedbackDetailTab: "info" | "comments" | "activity";
|
||||
feedbackFilters: { q: string; status: string; priority: string; category: string; assignee: string; page: number; perPage: number };
|
||||
feedbackPage: FeedbackPage;
|
||||
feedbackSelectedCodes: string[];
|
||||
feedbackTagInput: string;
|
||||
feedbackUpdate: { status: string; priority: string; statusDetail: string; publicReply: string; assignee: string; tags: string[] };
|
||||
labelPriority: AdminCommand;
|
||||
labelStatus: AdminCommand;
|
||||
loadFeedbacks: AdminCommand;
|
||||
openFeedback: AdminCommand;
|
||||
removeFeedbackTag: AdminCommand;
|
||||
retryFeedbackMail: AdminCommand;
|
||||
saveFeedbackUpdate: AdminCommand;
|
||||
selectedFeedback: FeedbackDetail | null;
|
||||
setFeedbackDetailTab: AdminCommand;
|
||||
setFeedbackTagInput: AdminCommand;
|
||||
statusTone: AdminCommand;
|
||||
toggleAllFeedbackCodes: AdminCommand;
|
||||
toggleFeedbackCode: AdminCommand;
|
||||
}
|
||||
|
||||
export interface SourcesViewContext {
|
||||
checkSources: AdminCommand;
|
||||
formatDateTime: AdminCommand;
|
||||
labelStatus: AdminCommand;
|
||||
saveSource: AdminCommand;
|
||||
sourceAvailability: number;
|
||||
sourceAverageLatency: number;
|
||||
sourceDraft: SourceEndpoint;
|
||||
sourceLastCheckedAt: string;
|
||||
sourceMaxLatency: number;
|
||||
sourceRows: SourceEndpoint[];
|
||||
statusTone: AdminCommand;
|
||||
}
|
||||
|
||||
export interface EndpointsViewContext {
|
||||
averageLatency: AdminCommand;
|
||||
copyEndpointToSource: AdminCommand;
|
||||
deleteEndpoint: AdminCommand;
|
||||
endpoints: SourceEndpoint[];
|
||||
endpointStatus: AdminCommand;
|
||||
formatDateTime: AdminCommand;
|
||||
healthyEndpointCount: number;
|
||||
labelStatus: AdminCommand;
|
||||
sourceCheckedAt: AdminCommand;
|
||||
sourceLatency: AdminCommand;
|
||||
statusTone: AdminCommand;
|
||||
visibleEndpointCount: number;
|
||||
}
|
||||
|
||||
export interface AuditViewContext {
|
||||
auditMessage: AdminCommand;
|
||||
auditPage: AuditPageState;
|
||||
auditTypeLabel: AdminCommand;
|
||||
loadAudit: AdminCommand;
|
||||
selectAuditLog: AdminCommand;
|
||||
setAuditPage: AdminCommand;
|
||||
}
|
||||
|
||||
export interface ReleasesViewContext {
|
||||
cancelUpload: AdminCommand;
|
||||
formatBytes: AdminCommand;
|
||||
noticeDraft: Record<string, unknown>;
|
||||
onPackageSelected: AdminCommand;
|
||||
openNotice: AdminCommand;
|
||||
releaseNotices: ReleaseNotice[];
|
||||
releasePackages: ReleasePackage[];
|
||||
releases: ReleaseManifest | null;
|
||||
restoreNotice: AdminCommand;
|
||||
saveNotice: AdminCommand;
|
||||
selectedNotice: ReleaseNotice | null;
|
||||
uploadDraft: Record<string, unknown>;
|
||||
uploadPackage: AdminCommand;
|
||||
validateNotice: AdminCommand;
|
||||
}
|
||||
|
||||
export interface LegacyViewContext {
|
||||
activeLegacyLabel: string;
|
||||
activeLegacyName: "update-info" | "media-types";
|
||||
activeMediaCategory: Record<string, unknown> | null;
|
||||
activeMediaCategoryIndex: number;
|
||||
applyLegacyModal: AdminCommand;
|
||||
closeLegacyModal: AdminCommand;
|
||||
legacyDocuments: Record<string, Record<string, unknown>>;
|
||||
legacyDrafts: Record<string, Record<string, unknown>>;
|
||||
legacyModal: Record<string, unknown>;
|
||||
openMediaCategoryModal: AdminCommand;
|
||||
openMediaSubcategoryModal: AdminCommand;
|
||||
openUpdateMirrorModal: AdminCommand;
|
||||
pretty: AdminCommand;
|
||||
removeItem: AdminCommand;
|
||||
restoreLegacy: AdminCommand;
|
||||
saveLegacy: AdminCommand;
|
||||
selectMediaCategory: AdminCommand;
|
||||
updateLegacyRawFromForm: AdminCommand;
|
||||
validateLegacy: AdminCommand;
|
||||
}
|
||||
|
||||
export interface SystemViewContext {
|
||||
auditMessage: AdminCommand;
|
||||
auditPage: AuditPageState;
|
||||
auditTypeLabel: AdminCommand;
|
||||
branding: Branding;
|
||||
changePassword: AdminCommand;
|
||||
database: DatabaseStatus | null;
|
||||
databaseConfig: DatabaseConfig | null;
|
||||
databaseConfigCollapsed: boolean;
|
||||
databaseConfigSummary: AdminCommand;
|
||||
databaseForm: Record<string, unknown>;
|
||||
databaseFormEditing: boolean;
|
||||
databaseLastSync: Record<string, unknown> | null;
|
||||
databaseSyncDirectionLabel: AdminCommand;
|
||||
databaseSyncJob: DatabaseSyncJob | null;
|
||||
databaseSyncOutput: string[];
|
||||
databaseSyncStatusLabel: AdminCommand;
|
||||
databaseSyncTableCount: AdminCommand;
|
||||
editDatabaseConfig: AdminCommand;
|
||||
healthSnapshot: Record<string, unknown> | null;
|
||||
labelStatus: AdminCommand;
|
||||
legacySync: Record<string, unknown>;
|
||||
legacySyncMode: string;
|
||||
loadAudit: AdminCommand;
|
||||
loadMigrationStatus: AdminCommand;
|
||||
loadSystemLogs: AdminCommand;
|
||||
mailConfig: MailConfig;
|
||||
mailConfigEditing: boolean;
|
||||
markDatabaseFormEditing: AdminCommand;
|
||||
markMailConfigEditing: AdminCommand;
|
||||
migrationStatus: MigrationStatus | null;
|
||||
passwordForm: { currentPassword: string; newPassword: string };
|
||||
pretty: AdminCommand;
|
||||
previewLegacySync: AdminCommand;
|
||||
refreshPreflight: AdminCommand;
|
||||
reloadDatabaseConfig: AdminCommand;
|
||||
reloadMailConfig: AdminCommand;
|
||||
runLegacySync: AdminCommand;
|
||||
saveBranding: AdminCommand;
|
||||
saveDatabase: AdminCommand;
|
||||
saveMailConfig: AdminCommand;
|
||||
selectAuditLog: AdminCommand;
|
||||
selectSystemLog: AdminCommand;
|
||||
setAuditPage: AdminCommand;
|
||||
setSystemLogPage: AdminCommand;
|
||||
setSystemTab: AdminCommand;
|
||||
statusTone: AdminCommand;
|
||||
syncDatabase: AdminCommand;
|
||||
systemLogPage: SystemLogPageState;
|
||||
systemTab: string;
|
||||
testDatabase: AdminCommand;
|
||||
testMail: AdminCommand;
|
||||
}
|
||||
export type { Branding };
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createEventBatcher } from "./eventBatcher";
|
||||
import { createLatestRequest } from "./latestRequest";
|
||||
|
||||
describe("admin async coordination", () => {
|
||||
it("applies only the latest request result and aborts the previous request", async () => {
|
||||
const latest = createLatestRequest();
|
||||
let resolveFirst: (value: string) => void = () => undefined;
|
||||
let firstSignal: AbortSignal | undefined;
|
||||
const first = latest.run((signal) => {
|
||||
firstSignal = signal;
|
||||
return new Promise<string>((resolve) => { resolveFirst = resolve; });
|
||||
});
|
||||
const second = latest.run(async () => "second");
|
||||
resolveFirst("first");
|
||||
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
await expect(second).resolves.toBe("second");
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("coalesces rapid SSE events into one 250ms batch", () => {
|
||||
vi.useFakeTimers();
|
||||
const flush = vi.fn();
|
||||
const batcher = createEventBatcher(250, flush);
|
||||
batcher.push("source_check.item");
|
||||
batcher.push("source_check.progress");
|
||||
batcher.push("source_check.item");
|
||||
|
||||
vi.advanceTimersByTime(249);
|
||||
expect(flush).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(flush).toHaveBeenCalledOnce();
|
||||
expect([...flush.mock.calls[0][0]]).toEqual(["source_check.item", "source_check.progress"]);
|
||||
batcher.cancel();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export type EventBatcher = {
|
||||
push(kind: string): void;
|
||||
cancel(): void;
|
||||
};
|
||||
|
||||
export function createEventBatcher(delayMs: number, flush: (kinds: ReadonlySet<string>) => void): EventBatcher {
|
||||
const pending = new Set<string>();
|
||||
let timer: number | undefined;
|
||||
|
||||
return {
|
||||
push(kind: string) {
|
||||
pending.add(kind);
|
||||
if (timer !== undefined) return;
|
||||
timer = window.setTimeout(() => {
|
||||
timer = undefined;
|
||||
const batch = new Set(pending);
|
||||
pending.clear();
|
||||
flush(batch);
|
||||
}, Math.max(0, delayMs));
|
||||
},
|
||||
cancel() {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type LatestRequest = {
|
||||
run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T | undefined>;
|
||||
cancel(reason?: string): void;
|
||||
};
|
||||
|
||||
export function createLatestRequest(): LatestRequest {
|
||||
let controller: AbortController | null = null;
|
||||
let serial = 0;
|
||||
|
||||
return {
|
||||
async run<T>(task: (signal: AbortSignal) => Promise<T>) {
|
||||
controller?.abort("superseded");
|
||||
const current = new AbortController();
|
||||
controller = current;
|
||||
const requestSerial = ++serial;
|
||||
try {
|
||||
const value = await task(current.signal);
|
||||
return requestSerial === serial ? value : undefined;
|
||||
} catch (error) {
|
||||
if (current.signal.aborted) return undefined;
|
||||
throw error;
|
||||
} finally {
|
||||
if (controller === current) controller = null;
|
||||
}
|
||||
},
|
||||
cancel(reason = "cancelled") {
|
||||
serial++;
|
||||
controller?.abort(reason);
|
||||
controller = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { adminResourceDiagnostic, adminResourceMarker, claimAdminResourceReload } from "./resourceRecovery";
|
||||
|
||||
describe("admin resource recovery", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
|
||||
it("permits one automatic reload per build and route", () => {
|
||||
const marker = adminResourceMarker("build-7", { href: "https://example.test/admin/feedbacks?_admin_reload=1&status=new" });
|
||||
expect(marker).toBe("build-7:/admin/feedbacks?status=new");
|
||||
expect(claimAdminResourceReload(sessionStorage, "reload", marker)).toBe(true);
|
||||
expect(claimAdminResourceReload(sessionStorage, "reload", marker)).toBe(false);
|
||||
expect(claimAdminResourceReload(sessionStorage, "reload", marker.replace("build-7", "build-8"))).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a stable diagnostic number without exposing the URL", () => {
|
||||
const first = adminResourceDiagnostic("build-7", { pathname: "/admin/feedbacks", search: "?status=new" });
|
||||
const second = adminResourceDiagnostic("build-7", { pathname: "/admin/feedbacks", search: "?status=new" });
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^ADMIN-ASSET-[A-Z0-9]+$/);
|
||||
expect(first).not.toContain("feedbacks");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export function adminResourceMarker(buildId: string, locationValue: Pick<Location, "href">) {
|
||||
const canonical = new URL(locationValue.href);
|
||||
canonical.searchParams.delete("_admin_reload");
|
||||
return `${buildId}:${canonical.pathname}${canonical.search}`;
|
||||
}
|
||||
|
||||
export function claimAdminResourceReload(storage: Pick<Storage, "getItem" | "setItem">, key: string, marker: string) {
|
||||
if (storage.getItem(key) === marker) return false;
|
||||
storage.setItem(key, marker);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function adminResourceDiagnostic(buildId: string, locationValue: Pick<Location, "pathname" | "search">) {
|
||||
const route = `${locationValue.pathname}${locationValue.search}`;
|
||||
let hash = 2166136261;
|
||||
for (const character of `${buildId}:${route}`) {
|
||||
hash ^= character.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `ADMIN-ASSET-${Math.abs(hash).toString(36).toUpperCase()}`;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Search, ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||
import type { AuditViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
defineProps<{ ctx: AuditViewContext }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import VChart from "vue-echarts";
|
||||
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, Gauge, PauseCircle, PlayCircle, TimerReset } from "lucide-vue-next";
|
||||
import LatencyTrendChart from "../components/LatencyTrendChart.vue";
|
||||
import type { DashboardViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
defineProps<{ ctx: DashboardViewContext }>();
|
||||
|
||||
use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,7 +22,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
</button>
|
||||
<span class="muted">每 20 秒自动刷新接口状态。</span>
|
||||
<span class="badge neutral">实时更新</span>
|
||||
<span v-if="ctx.sourceLastCheckedAt" class="muted">最近检测:{{ ctx.formatDateTime(ctx.sourceLastCheckedAt) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -59,17 +55,52 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-panel chart-panel-relative">
|
||||
<h2>所有接口平均延迟</h2>
|
||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||
<LatencyTrendChart v-if="!ctx.dashboardPending && !ctx.dashboardError && !ctx.isAverageLatencyChartEmpty" :points="ctx.averageLatencyRows" />
|
||||
<div v-if="ctx.dashboardPending" class="chart-state chart-loading" aria-live="polite">正在加载延迟趋势...</div>
|
||||
<div v-else-if="ctx.dashboardError" class="chart-state chart-error" role="alert">{{ ctx.dashboardError }}</div>
|
||||
<div v-if="ctx.isAverageLatencyChartEmpty" class="chart-empty">
|
||||
<strong>暂无平均延迟记录</strong>
|
||||
<span>服务端 5 秒检测产生记录后会自动绘制趋势。</span>
|
||||
<span>尚无检测样本。</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>反馈状态分布</h2><VChart class="chart" :option="ctx.feedbackOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>服务可用率</h2><VChart class="chart" :option="ctx.availabilityOption" autoresize /></section>
|
||||
<section class="panel chart-panel chart-panel-relative">
|
||||
<h2>接口健康分布</h2>
|
||||
<div v-if="ctx.hasSourceHealthData" class="distribution-list" role="img" aria-label="接口健康分布">
|
||||
<div v-for="item in ctx.healthDistributionRows" :key="item.key" class="distribution-row">
|
||||
<span>{{ item.label }}</span><div><i :style="{ width: `${item.percent}%`, backgroundColor: item.color }"></i></div><strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chart-state">暂无接口健康数据</div>
|
||||
</section>
|
||||
<section class="panel chart-panel chart-panel-relative">
|
||||
<h2>反馈状态分布</h2>
|
||||
<div v-if="ctx.hasFeedbackStatusData" class="distribution-list" role="img" aria-label="反馈状态分布">
|
||||
<div v-for="item in ctx.feedbackDistributionRows" :key="item.name" class="distribution-row">
|
||||
<span>{{ item.name }}</span><div><i :style="{ width: `${item.percent}%` }"></i></div><strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chart-state">暂无反馈状态数据</div>
|
||||
</section>
|
||||
<section class="panel availability-panel">
|
||||
<div class="section-head"><h2>服务可用率</h2><strong>{{ ctx.sourceAvailability || 0 }}%</strong></div>
|
||||
<div class="bullet-track" role="progressbar" aria-label="服务可用率" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="ctx.sourceAvailability || 0">
|
||||
<span :style="{ width: `${Math.max(0, Math.min(100, Number(ctx.sourceAvailability) || 0))}%` }"></span>
|
||||
</div>
|
||||
<div class="bullet-scale"><span>异常</span><span>需关注</span><span>稳定</span></div>
|
||||
<p class="muted">{{ ctx.healthyEndpointCount || 0 }} 个健康接口,共 {{ ctx.visibleEndpointCount || 0 }} 个对客户端可见接口。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<details class="panel data-fallback">
|
||||
<summary>查看仪表盘数据表</summary>
|
||||
<div class="table-scroll"><table><thead><tr><th>时间</th><th>平均延迟</th><th>样本数</th></tr></thead><tbody>
|
||||
<tr v-for="item in ctx.averageLatencyRows" :key="`${item.checkedAt}-${item.label}`"><td>{{ item.label }}</td><td>{{ item.latency }}ms</td><td>{{ item.sampleCount || 0 }}</td></tr>
|
||||
<tr v-if="!ctx.averageLatencyRows.length"><td colspan="3">暂无趋势数据。</td></tr>
|
||||
</tbody></table></div>
|
||||
</details>
|
||||
|
||||
<div v-if="ctx.dashboardWarnings.length" class="notice" role="status">部分指标暂不可用:{{ ctx.dashboardWarnings.join(";") }}</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>每接口实时延迟</h2><span class="badge">{{ ctx.sourceRows.length }} 个接口</span></div>
|
||||
<div class="table-scroll">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Trash2 } from "lucide-vue-next";
|
||||
import type { EndpointsViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
defineProps<{ ctx: EndpointsViewContext }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,7 +10,6 @@ defineProps<{ ctx: any }>();
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>客户端动态接口</h2>
|
||||
<p class="muted">删除接口后会由服务端重新生成兼容媒体源 JSON 和更新 JSON。</p>
|
||||
</div>
|
||||
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Mail, Save, Search, UploadCloud, Tag, X, ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||
import type { FeedbackViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
defineProps<{ ctx: FeedbackViewContext }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Pencil, Plus, Save, Trash2 } from "lucide-vue-next";
|
||||
import type { LegacyViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
defineProps<{ ctx: LegacyViewContext }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,7 +10,6 @@ defineProps<{ ctx: any }>();
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||
<p class="muted">可视化表单只维护常用字段,保存时会合并回当前 JSON,未识别字段继续保留。</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
@@ -102,7 +102,6 @@ defineProps<{ ctx: any }>();
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>{{ ctx.activeMediaCategory?.name || ctx.activeMediaCategory?.id || "子接口" }}</h3>
|
||||
<p class="muted">右侧仅显示当前选中分类下的子接口。</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" :disabled="!ctx.activeMediaCategory" @click="ctx.openMediaCategoryModal(ctx.activeMediaCategoryIndex)"><Pencil :size="14" />编辑分类</button>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { reactive } from "vue";
|
||||
import LoginView from "./LoginView.vue";
|
||||
|
||||
function mountLogin(overrides: Record<string, unknown> = {}) {
|
||||
const ctx = reactive({
|
||||
branding: { siteName: "YMhut Box", adminSubtitle: "统一管理台" },
|
||||
captcha: { image: "data:image/png;base64,test" },
|
||||
captchaPending: false,
|
||||
loginForm: { username: "", password: "", captcha: "" },
|
||||
loginPending: false,
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
refreshCaptcha: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
});
|
||||
const wrapper = mount(LoginView, {
|
||||
props: { ctx },
|
||||
global: {
|
||||
stubs: {
|
||||
Button: {
|
||||
props: ["label", "disabled"],
|
||||
template: '<button type="submit" :disabled="disabled">{{ label }}</button>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { ctx, wrapper };
|
||||
}
|
||||
|
||||
describe("LoginView", () => {
|
||||
it("does not expose default credentials or a pre-login default-password warning", () => {
|
||||
const { wrapper } = mountLogin();
|
||||
|
||||
expect(wrapper.text()).not.toContain("默认账号");
|
||||
expect(wrapper.text()).not.toContain("默认密码");
|
||||
expect(wrapper.find('input[autocomplete="username"]').element.getAttribute("value")).toBeNull();
|
||||
});
|
||||
|
||||
it("submits through the login action and can refresh the captcha", async () => {
|
||||
const { ctx, wrapper } = mountLogin();
|
||||
|
||||
await wrapper.get('input[autocomplete="username"]').setValue("operator");
|
||||
await wrapper.get('input[autocomplete="current-password"]').setValue("secret");
|
||||
await wrapper.get("form").trigger("submit");
|
||||
await wrapper.get(".captcha-button").trigger("click");
|
||||
|
||||
expect(ctx.loginForm.username).toBe("operator");
|
||||
expect(ctx.login).toHaveBeenCalledOnce();
|
||||
expect(ctx.refreshCaptcha).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import Button from "primevue/button";
|
||||
import type { LoginViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: LoginViewContext }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-shell">
|
||||
<section class="login-panel" aria-labelledby="admin-login-title">
|
||||
<div>
|
||||
<p class="eyebrow">{{ ctx.branding.siteName }}</p>
|
||||
<h1 id="admin-login-title">后台登录</h1>
|
||||
<p class="muted">{{ ctx.branding.adminSubtitle }}</p>
|
||||
</div>
|
||||
<form class="form-stack" @submit.prevent="ctx.login">
|
||||
<label>账号<input v-model.trim="ctx.loginForm.username" autocomplete="username" :disabled="ctx.loginPending" /></label>
|
||||
<label>密码<input v-model="ctx.loginForm.password" type="password" autocomplete="current-password" :disabled="ctx.loginPending" /></label>
|
||||
<label>
|
||||
验证码
|
||||
<div class="captcha-row">
|
||||
<input v-model.trim="ctx.loginForm.captcha" :disabled="ctx.loginPending" autocomplete="off" />
|
||||
<button class="captcha-button" type="button" title="刷新验证码" :disabled="ctx.loginPending || ctx.captchaPending" @click="ctx.refreshCaptcha">
|
||||
<img v-if="ctx.captcha?.image" :src="ctx.captcha.image" alt="验证码" />
|
||||
<span v-else>{{ ctx.captchaPending ? "加载中" : "刷新" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<Button class="full" type="submit" :label="ctx.loginPending ? '正在登录…' : '登录'" :loading="ctx.loginPending" :disabled="ctx.loginPending" />
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Save, UploadCloud } from "lucide-vue-next";
|
||||
import { CheckCircle2, Save, UploadCloud, X } from "lucide-vue-next";
|
||||
import type { ReleasesViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
defineProps<{ ctx: ReleasesViewContext }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,7 +41,10 @@ defineProps<{ ctx: any }>();
|
||||
</template>
|
||||
</small>
|
||||
</div>
|
||||
<button class="btn primary" :disabled="ctx.uploadDraft.uploading" @click="ctx.uploadPackage"><UploadCloud :size="16" />{{ ctx.uploadDraft.uploading ? "上传中" : "上传发布包" }}</button>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" :disabled="ctx.uploadDraft.uploading" @click="ctx.uploadPackage"><UploadCloud :size="16" />{{ ctx.uploadDraft.uploading ? "上传中" : "上传发布包" }}</button>
|
||||
<button v-if="ctx.uploadDraft.uploading" class="btn ghost danger" @click="ctx.cancelUpload"><X :size="16" />取消上传</button>
|
||||
</div>
|
||||
</section>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
|
||||
@@ -60,7 +64,6 @@ defineProps<{ ctx: any }>();
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>版本日志</h2>
|
||||
<p class="muted">以 update-info.json 模板为基础动态生成更新信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="revision-list">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
import type { SourcesViewContext } from "../types/admin";
|
||||
|
||||
defineProps<{ ctx: SourcesViewContext }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -8,7 +10,6 @@ defineProps<{ ctx: any }>();
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>媒体/数据源</h2>
|
||||
<p class="muted">服务端每 20 秒独立检测所有启用接口,表格展示最近一次状态与延迟。</p>
|
||||
</div>
|
||||
<button class="btn primary" @click="ctx.checkSources">批量检测</button>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@ 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 type { SystemViewContext } from "../types/admin";
|
||||
import Textarea from "primevue/textarea";
|
||||
|
||||
const props = defineProps<{ ctx: any }>();
|
||||
const props = defineProps<{ ctx: SystemViewContext }>();
|
||||
const syncOutputRef = ref<HTMLElement | null>(null);
|
||||
|
||||
watch(
|
||||
@@ -206,7 +206,6 @@ tabs.splice(tabs.length - 1, 0, { id: "logs", label: "日志中心", icon: ListC
|
||||
<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>
|
||||
@@ -271,7 +270,17 @@ tabs.splice(tabs.length - 1, 0, { id: "logs", label: "日志中心", icon: ListC
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'health'" class="panel page-stack">
|
||||
<div class="section-head"><h2>健康快照</h2><span class="badge neutral">只读</span></div>
|
||||
<div class="section-head">
|
||||
<h2>健康快照</h2>
|
||||
<button class="btn ghost" @click="ctx.refreshPreflight">重新预检</button>
|
||||
</div>
|
||||
<div class="runtime-status">
|
||||
<div><span>资源模式</span><strong>{{ ctx.healthSnapshot?.adminAssets?.mode || '-' }}</strong></div>
|
||||
<div><span>后台构建</span><strong class="mono">{{ ctx.healthSnapshot?.adminAssets?.buildId || '-' }}</strong></div>
|
||||
<div><span>资源清单</span><strong :class="ctx.healthSnapshot?.adminAssets?.ready ? 'latency-value' : 'latency-value slow'">{{ ctx.healthSnapshot?.adminAssets?.manifestStatus || '-' }}</strong></div>
|
||||
<div><span>预检时间</span><strong>{{ ctx.healthSnapshot?.preflightCheckedAt || '-' }}</strong></div>
|
||||
</div>
|
||||
<div v-if="ctx.healthSnapshot?.adminAssets?.validationError" class="notice">{{ ctx.healthSnapshot.adminAssets.validationError }}</div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
const adminBuildId = process.env.YMHUT_ADMIN_BUILD_ID?.trim() || "dev";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
__ADMIN_BUILD_ID__: JSON.stringify(adminBuildId),
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
name: "ymhut-admin-build-metadata",
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "admin-build.json",
|
||||
source: `${JSON.stringify({ buildId: adminBuildId }, null, 2)}\n`,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
build: {
|
||||
manifest: "asset-manifest.json",
|
||||
chunkSizeWarningLimit: 650,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ["vue", "vue-router"],
|
||||
charts: ["echarts", "vue-echarts"],
|
||||
icons: ["lucide-vue-next"],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user