@@ -1,35 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
Database,
|
||||
FileJson,
|
||||
HeartPulse,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
} from "lucide-vue-next";
|
||||
import AuditView from "./views/AuditView.vue";
|
||||
import DashboardView from "./views/DashboardView.vue";
|
||||
import DatabaseView from "./views/DatabaseView.vue";
|
||||
import EndpointsView from "./views/EndpointsView.vue";
|
||||
import FeedbacksView from "./views/FeedbacksView.vue";
|
||||
import HealthView from "./views/HealthView.vue";
|
||||
import LegacyJsonView from "./views/LegacyJsonView.vue";
|
||||
import ReleasesView from "./views/ReleasesView.vue";
|
||||
import SettingsView from "./views/SettingsView.vue";
|
||||
import SourcesView from "./views/SourcesView.vue";
|
||||
import SystemView from "./views/SystemView.vue";
|
||||
import { adminFetch, toChineseError, uploadAdminFile } from "./api/admin";
|
||||
import { createAuthStore } from "./stores/auth";
|
||||
import { createDashboardStore } 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";
|
||||
|
||||
type LegacyName = "update-info" | "media-types";
|
||||
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
|
||||
|
||||
type SystemTab = "database" | "sync" | "security" | "health" | "audit";
|
||||
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||
|
||||
type Captcha = {
|
||||
@@ -50,7 +52,6 @@ type RouteItem = {
|
||||
icon: any;
|
||||
};
|
||||
|
||||
const csrf = ref(localStorage.getItem("ymhut.csrf") || "");
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||
@@ -61,62 +62,21 @@ let refreshTimer: number | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
let events: EventSource | null = null;
|
||||
|
||||
const captcha = ref<Captcha | null>(null);
|
||||
const authBootstrap = ref<AuthBootstrap | null>(null);
|
||||
const dashboard = ref<any>({});
|
||||
const sourceCheckJobs = ref<any[]>([]);
|
||||
const feedbackPage = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selectedFeedback = ref<any | null>(null);
|
||||
const releases = ref<any>(null);
|
||||
const releaseNotices = ref<any[]>([]);
|
||||
const selectedNotice = ref<any | null>(null);
|
||||
const sources = ref<any>({ categories: [] });
|
||||
const endpoints = ref<any[]>([]);
|
||||
const database = ref<any>(null);
|
||||
const healthSnapshot = ref<any>(null);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const legacySync = ref<any>(null);
|
||||
const legacyDocuments = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
|
||||
const authStore = createAuthStore();
|
||||
const dashboardStore = createDashboardStore();
|
||||
const feedbackStore = createFeedbackStore();
|
||||
const releaseStore = createReleaseStore();
|
||||
const legacyStore = createLegacyStore();
|
||||
const sourceStore = createSourceStore();
|
||||
const systemStore = createSystemStore();
|
||||
|
||||
const loginForm = reactive({ username: "admin", password: "", captcha: "" });
|
||||
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
|
||||
const feedbackFilters = reactive({ q: "", status: "", page: 1, perPage: 20 });
|
||||
const feedbackUpdate = reactive({ status: "", statusDetail: "", publicReply: "" });
|
||||
const commentDraft = reactive({ body: "", internal: true });
|
||||
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
|
||||
const sourceDraft = reactive({
|
||||
sourceId: "",
|
||||
categoryId: "custom",
|
||||
categoryName: "自定义接口",
|
||||
name: "",
|
||||
description: "",
|
||||
method: "GET",
|
||||
apiUrl: "",
|
||||
urlTemplate: "",
|
||||
thumbnailUrl: "",
|
||||
proxyMode: "client_direct",
|
||||
timeoutMs: 8000,
|
||||
retryCount: 1,
|
||||
cacheSeconds: 300,
|
||||
checkIntervalSec: 300,
|
||||
enabled: true,
|
||||
clientVisible: true,
|
||||
supportedFormats: "[\"json\"]",
|
||||
});
|
||||
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
|
||||
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
||||
});
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
const uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
channel: "stable",
|
||||
notes: "",
|
||||
updateManifest: true,
|
||||
});
|
||||
const { csrf, captcha, bootstrap: authBootstrap, loginForm, passwordForm } = authStore;
|
||||
const { dashboard, sourceCheckJobs } = dashboardStore;
|
||||
const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters, update: feedbackUpdate, commentDraft } = feedbackStore;
|
||||
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
|
||||
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts } = legacyStore;
|
||||
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
||||
const { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode } = systemStore;
|
||||
|
||||
const routes: RouteItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||
@@ -126,10 +86,7 @@ const routes: RouteItem[] = [
|
||||
{ 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/database", label: "数据库与同步", description: "SQLite、MySQL 和旧项目同步", icon: Database },
|
||||
{ path: "/admin/health", label: "健康快照", description: "服务端运行状态和预检信息", icon: HeartPulse },
|
||||
{ path: "/admin/settings", label: "系统设置", description: "密码与旧库同步入口", icon: Settings },
|
||||
{ path: "/admin/audit", label: "审计日志", description: "后台操作和同步记录", icon: ListChecks },
|
||||
{ path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
|
||||
];
|
||||
|
||||
const navGroups = [
|
||||
@@ -137,17 +94,7 @@ const navGroups = [
|
||||
{ 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/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(item.path)) },
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ path: "/admin/feedbacks", label: "反馈处理", description: "查看和处理客户端反馈工单", icon: MessageSquareText },
|
||||
{ path: "/admin/releases", label: "发布与日志", description: "维护发布包和 update-notice", 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/database", label: "数据库同步", description: "管理 SQLite/MySQL 和旧项目同步", icon: Database },
|
||||
{ path: "/admin/audit", label: "审计日志", description: "查看后台操作与同步记录", icon: ListChecks },
|
||||
{ label: "系统运维", items: routes.filter((item) => ["/admin/system"].includes(item.path)) },
|
||||
];
|
||||
|
||||
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
|
||||
@@ -167,6 +114,7 @@ const visibleEndpointCount = computed(() => endpoints.value.filter((item) => ite
|
||||
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["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 systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
|
||||
|
||||
const heartbeatOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
@@ -261,6 +209,9 @@ const viewContext = computed(() => ({
|
||||
copyEndpointToSource,
|
||||
database: database.value,
|
||||
databaseForm,
|
||||
databaseLastSync: databaseLastSync.value,
|
||||
databaseSyncDirectionLabel,
|
||||
databaseSyncTableCount,
|
||||
endpointStatus,
|
||||
endpoints: endpoints.value,
|
||||
feedbackFilters,
|
||||
@@ -280,6 +231,7 @@ const viewContext = computed(() => ({
|
||||
legacyDocuments,
|
||||
legacyDrafts,
|
||||
legacySync: legacySync.value,
|
||||
legacySyncMode: legacySyncMode.value,
|
||||
loadAudit,
|
||||
loadFeedbacks,
|
||||
navigate,
|
||||
@@ -291,7 +243,6 @@ const viewContext = computed(() => ({
|
||||
pretty,
|
||||
previewLegacySync,
|
||||
removeItem,
|
||||
quickActions,
|
||||
releaseNotices: releaseNotices.value,
|
||||
releasePackages: releasePackages.value,
|
||||
releases: releases.value,
|
||||
@@ -309,6 +260,8 @@ const viewContext = computed(() => ({
|
||||
sourceDraft,
|
||||
statusTone,
|
||||
syncDatabase,
|
||||
systemTab: systemTab.value,
|
||||
setSystemTab,
|
||||
testDatabase,
|
||||
toggleAutoRefresh,
|
||||
updateLegacyRawFromForm,
|
||||
@@ -322,21 +275,26 @@ const viewContext = computed(() => ({
|
||||
}));
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
||||
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
|
||||
return data as T;
|
||||
return adminFetch<T>(target, init, { csrf: csrf.value });
|
||||
}
|
||||
|
||||
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 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";
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSystemTab(value: unknown): SystemTab {
|
||||
const tab = Array.isArray(value) ? value[0] : value;
|
||||
if (tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
|
||||
return "database";
|
||||
}
|
||||
|
||||
function navigate(next: string) {
|
||||
if (currentPath.value === next) {
|
||||
void load();
|
||||
@@ -345,6 +303,10 @@ function navigate(next: string) {
|
||||
void router.push(next);
|
||||
}
|
||||
|
||||
function setSystemTab(tab: SystemTab) {
|
||||
void router.replace({ path: "/admin/system", query: tab === "database" ? {} : { tab } });
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshPaused.value = !autoRefreshPaused.value;
|
||||
}
|
||||
@@ -362,9 +324,15 @@ async function guarded(task: () => Promise<void>) {
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
const message = toChineseError(rawMessage);
|
||||
setToast(message, "error");
|
||||
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
|
||||
if (isAuthError(rawMessage, message)) {
|
||||
csrf.value = "";
|
||||
sessionStorage.removeItem("ymhut.csrf");
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
events?.close();
|
||||
events = null;
|
||||
navigate("/admin/login");
|
||||
}
|
||||
} finally {
|
||||
@@ -372,6 +340,11 @@ async function guarded(task: () => Promise<void>) {
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthError(raw: string, message: string) {
|
||||
const text = `${raw} ${message}`.toLowerCase();
|
||||
return text.includes("unauthorized") || text.includes("login required") || text.includes("401") || message.includes("需要登录");
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
captcha.value = await api<Captcha>("/api/admin/auth/captcha");
|
||||
}
|
||||
@@ -387,7 +360,8 @@ async function login() {
|
||||
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
|
||||
});
|
||||
csrf.value = data.csrfToken;
|
||||
localStorage.setItem("ymhut.csrf", csrf.value);
|
||||
sessionStorage.setItem("ymhut.csrf", csrf.value);
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
connectAdminEvents();
|
||||
navigate("/admin/dashboard");
|
||||
});
|
||||
@@ -396,6 +370,7 @@ async function login() {
|
||||
async function logout() {
|
||||
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
|
||||
csrf.value = "";
|
||||
sessionStorage.removeItem("ymhut.csrf");
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
events?.close();
|
||||
events = null;
|
||||
@@ -408,17 +383,19 @@ async function load() {
|
||||
await Promise.all([loadAuthBootstrap(), loadCaptcha()]);
|
||||
return;
|
||||
}
|
||||
if (!csrf.value) {
|
||||
navigate("/admin/login");
|
||||
return;
|
||||
}
|
||||
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();
|
||||
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
|
||||
if (currentPath.value === "/admin/database") await loadDatabase();
|
||||
if (currentPath.value === "/admin/health") await loadHealth();
|
||||
if (currentPath.value === "/admin/audit") await loadAudit();
|
||||
if (currentPath.value === "/admin/settings") await previewLegacySync();
|
||||
if (currentPath.value === "/admin/system") await loadSystem();
|
||||
const legacyName = activeLegacyName.value;
|
||||
if (legacyName) await loadLegacy(legacyName);
|
||||
connectAdminEvents();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,6 +403,10 @@ async function loadDashboard() {
|
||||
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
|
||||
}
|
||||
|
||||
async function loadSystem() {
|
||||
await Promise.all([loadDatabase(), loadHealth(), loadAudit()]);
|
||||
}
|
||||
|
||||
async function loadFeedbacks() {
|
||||
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
|
||||
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
|
||||
@@ -540,6 +521,10 @@ async function loadLegacy(name: LegacyName) {
|
||||
function onPackageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
uploadDraft.file = input.files?.[0] || null;
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
|
||||
uploadDraft.status = uploadDraft.file ? "等待上传" : "";
|
||||
if (uploadDraft.file && !uploadDraft.version) {
|
||||
const version = uploadDraft.file.name.match(/\d+\.\d+\.\d+(?:\.\d+)?/)?.[0];
|
||||
if (version) uploadDraft.version = version;
|
||||
@@ -560,11 +545,33 @@ async function uploadPackage() {
|
||||
form.append("channel", uploadDraft.channel);
|
||||
form.append("notes", uploadDraft.notes);
|
||||
form.append("updateManifest", String(uploadDraft.updateManifest));
|
||||
await api("/api/admin/releases/packages", { method: "POST", body: form, headers: {} });
|
||||
uploadDraft.uploading = true;
|
||||
uploadDraft.status = "正在上传";
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
|
||||
await uploadWithProgress("/api/admin/releases/packages", form, (loaded, total) => {
|
||||
uploadDraft.loadedBytes = loaded;
|
||||
uploadDraft.totalBytes = total;
|
||||
uploadDraft.progress = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||
uploadDraft.status = uploadDraft.progress >= 100 ? "服务端处理中" : "正在上传";
|
||||
});
|
||||
uploadDraft.progress = 100;
|
||||
uploadDraft.status = "上传完成";
|
||||
uploadDraft.file = null;
|
||||
uploadDraft.notes = "";
|
||||
setToast("发布包已上传并放入下载目录");
|
||||
await loadReleases();
|
||||
window.setTimeout(() => {
|
||||
if (!uploadDraft.uploading) {
|
||||
uploadDraft.progress = 0;
|
||||
uploadDraft.loadedBytes = 0;
|
||||
uploadDraft.totalBytes = 0;
|
||||
uploadDraft.status = "";
|
||||
}
|
||||
}, 1200);
|
||||
}).finally(() => {
|
||||
uploadDraft.uploading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -727,6 +734,7 @@ async function checkSources() {
|
||||
setToast(`接口心跳检测已进入队列:${data.jobId}`);
|
||||
if (currentPath.value === "/admin/dashboard") await loadDashboard();
|
||||
if (currentPath.value === "/admin/sources") await loadSources();
|
||||
if (currentPath.value === "/admin/system") await loadSystem();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -759,11 +767,11 @@ function copyEndpointToSource(item: any) {
|
||||
navigate("/admin/sources");
|
||||
}
|
||||
|
||||
async function loadDatabase() {
|
||||
async function loadDatabase(options: { previewLegacy?: boolean } = {}) {
|
||||
const data = await api<{ database: any }>("/api/admin/database/status");
|
||||
database.value = data.database;
|
||||
databaseForm.provider = data.database?.configProvider || "sqlite";
|
||||
await previewLegacySync();
|
||||
if (options.previewLegacy !== false) await previewLegacySync();
|
||||
}
|
||||
|
||||
async function testDatabase() {
|
||||
@@ -778,21 +786,24 @@ async function testDatabase() {
|
||||
|
||||
async function syncDatabase(direction: "import" | "sync") {
|
||||
await guarded(async () => {
|
||||
await api(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
|
||||
const data = await api<{ result?: any; finishedAt?: string }>(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
|
||||
databaseLastSync.value = data.result || { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite", finishedAt: data.finishedAt };
|
||||
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
|
||||
await loadDatabase();
|
||||
await loadDatabase({ previewLegacy: false });
|
||||
});
|
||||
}
|
||||
|
||||
async function previewLegacySync() {
|
||||
legacySyncMode.value = "preview";
|
||||
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
|
||||
}
|
||||
|
||||
async function runLegacySync() {
|
||||
await guarded(async () => {
|
||||
legacySyncMode.value = "run";
|
||||
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
|
||||
setToast("旧项目同步已完成");
|
||||
await Promise.all([loadDatabase(), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||
await Promise.all([loadDatabase({ previewLegacy: false }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -875,6 +886,17 @@ function auditMessage(item: any) {
|
||||
return legacy[message] || message || auditTypeLabel(item?.type);
|
||||
}
|
||||
|
||||
function databaseSyncDirectionLabel(value: string) {
|
||||
if (value === "sqlite_to_remote") return "SQLite -> MySQL";
|
||||
if (value === "remote_to_sqlite") return "MySQL -> SQLite";
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function databaseSyncTableCount(result: any) {
|
||||
const tables = result?.tables || {};
|
||||
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
@@ -921,13 +943,17 @@ function splitList(value: string) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localStorage.removeItem("ymhut.csrf");
|
||||
void load();
|
||||
connectAdminEvents();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
watch(currentPath, () => {
|
||||
void load();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
events?.close();
|
||||
@@ -942,8 +968,7 @@ function connectAdminEvents() {
|
||||
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/audit") void loadAudit();
|
||||
if (currentPath.value === "/admin/database") void loadDatabase();
|
||||
if (currentPath.value === "/admin/system") void loadSystem();
|
||||
};
|
||||
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
|
||||
events.addEventListener(name, refreshCurrent);
|
||||
@@ -1001,7 +1026,7 @@ function connectAdminEvents() {
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.path"
|
||||
:class="{ active: currentPath === item.path || (item.path.includes('/legacy/') && activeLegacyName) }"
|
||||
:class="{ active: currentPath === item.path }"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" />
|
||||
@@ -1030,10 +1055,7 @@ function connectAdminEvents() {
|
||||
<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" />
|
||||
<DatabaseView v-else-if="currentPath === '/admin/database'" :ctx="viewContext" />
|
||||
<HealthView v-else-if="currentPath === '/admin/health'" :ctx="viewContext" />
|
||||
<SettingsView v-else-if="currentPath === '/admin/settings'" :ctx="viewContext" />
|
||||
<AuditView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
|
||||
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user