feat: complete 2.0.7.12 platform overhaul

This commit is contained in:
2026-08-16 19:33:03 +08:00
parent 73555cd04c
commit c9fa6f7a88
159 changed files with 13243 additions and 2539 deletions
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"
}
}
+390 -354
View File
@@ -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>
+10
View File
@@ -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;
}
+47 -57
View File
@@ -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"],
},
},