@@ -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>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
export type UploadProgress = {
|
||||
loaded: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AdminApiOptions = {
|
||||
csrf?: string;
|
||||
};
|
||||
|
||||
const exactMessages: Record<string, string> = {
|
||||
"current password is invalid": "当前密码不正确",
|
||||
"new password is required": "新密码不能为空",
|
||||
"new password must be at least 8 characters": "新密码至少需要 8 位",
|
||||
"new password cannot be admin": "新密码不能为 admin",
|
||||
"new password must be different from current password": "新密码不能与当前密码相同",
|
||||
"invalid password or captcha": "密码或验证码不正确",
|
||||
"login required": "需要登录后继续操作",
|
||||
"csrf token required": "页面安全令牌已失效,请刷新后重试",
|
||||
"csrf token invalid": "页面安全令牌无效,请刷新后重试",
|
||||
"code is required": "缺少反馈编号",
|
||||
"revisionid is required": "请选择要恢复的历史版本",
|
||||
"post required": "该操作需要使用 POST 请求",
|
||||
"get required": "该操作需要使用 GET 请求",
|
||||
"file is required": "请选择要上传的文件",
|
||||
"invalid filename": "文件名不合法",
|
||||
"path escape rejected": "文件路径不合法",
|
||||
"check job not found": "未找到心跳检测任务",
|
||||
"streaming is not supported": "当前运行环境不支持实时事件流",
|
||||
};
|
||||
|
||||
const codeMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: "需要登录后继续操作",
|
||||
LOGIN_FAILED: "登录失败,请检查密码和验证码",
|
||||
PASSWORD_CHANGE_FAILED: "密码修改失败",
|
||||
INVALID_PAYLOAD: "提交内容格式不正确",
|
||||
DATABASE_TEST_FAILED: "数据库连接测试失败",
|
||||
DATABASE_IMPORT_FAILED: "SQLite 导入远端库失败",
|
||||
DATABASE_SYNC_FAILED: "远端库同步回本地失败",
|
||||
LEGACY_SAVE_FAILED: "兼容 JSON 保存失败",
|
||||
LEGACY_VALIDATE_FAILED: "兼容 JSON 校验失败",
|
||||
LEGACY_RESTORE_FAILED: "兼容 JSON 恢复失败",
|
||||
NOTICE_SAVE_FAILED: "版本日志保存失败",
|
||||
NOTICE_VALIDATE_FAILED: "版本日志校验失败",
|
||||
NOTICE_RESTORE_FAILED: "版本日志恢复失败",
|
||||
PACKAGE_UPLOAD_FAILED: "发布包上传失败",
|
||||
SOURCE_SAVE_FAILED: "接口源保存失败",
|
||||
CHECK_FAILED: "接口健康检测失败",
|
||||
SYNC_FAILED: "同步操作失败",
|
||||
FORBIDDEN: "没有权限执行该操作",
|
||||
METHOD_NOT_ALLOWED: "请求方法不正确",
|
||||
};
|
||||
|
||||
export async function adminFetch<T>(target: string, init: RequestInit = {}, options: AdminApiOptions = {}): 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 (options.csrf) headers.set("X-CSRF-Token", options.csrf);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(toChineseError(data.message || data.error || `HTTP ${res.status}`));
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
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();
|
||||
xhr.open("POST", target);
|
||||
xhr.withCredentials = true;
|
||||
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, {});
|
||||
if (xhr.status < 200 || xhr.status >= 300 || data.ok === false) {
|
||||
reject(new Error(toChineseError(data.message || data.error || `HTTP ${xhr.status}`)));
|
||||
return;
|
||||
}
|
||||
resolve(data as T);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("网络异常,发布包上传失败"));
|
||||
xhr.onabort = () => reject(new Error("发布包上传已取消"));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
export function toChineseError(value: string) {
|
||||
const raw = String(value || "").trim();
|
||||
const lower = raw.toLowerCase();
|
||||
if (exactMessages[lower]) return exactMessages[lower];
|
||||
if (codeMessages[raw]) return codeMessages[raw];
|
||||
if (/^HTTP\s+\d+/.test(raw)) return `请求失败:${raw}`;
|
||||
return raw || "操作失败";
|
||||
}
|
||||
|
||||
function parseJSONSafe(value: string, fallback: any) {
|
||||
try {
|
||||
return JSON.parse(value || "{}");
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,17 @@ const routes = [
|
||||
"/admin/legacy/media-types",
|
||||
"/admin/sources",
|
||||
"/admin/endpoints",
|
||||
"/admin/database",
|
||||
"/admin/health",
|
||||
"/admin/settings",
|
||||
"/admin/audit",
|
||||
"/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" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createAuthStore() {
|
||||
const csrf = ref(sessionStorage.getItem("ymhut.csrf") || "");
|
||||
const captcha = ref<any | null>(null);
|
||||
const bootstrap = ref<any | null>(null);
|
||||
const loginForm = reactive({ username: "", password: "", captcha: "" });
|
||||
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
|
||||
|
||||
return { csrf, captcha, bootstrap, loginForm, passwordForm };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function createDashboardStore() {
|
||||
const dashboard = ref<any>({});
|
||||
const sourceCheckJobs = ref<any[]>([]);
|
||||
|
||||
return { dashboard, sourceCheckJobs };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createFeedbackStore() {
|
||||
const page = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
|
||||
const selected = ref<any | null>(null);
|
||||
const filters = reactive({ q: "", status: "", page: 1, perPage: 20 });
|
||||
const update = reactive({ status: "", statusDetail: "", publicReply: "" });
|
||||
const commentDraft = reactive({ body: "", internal: true });
|
||||
|
||||
return { page, selected, filters, update, commentDraft };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export type LegacyName = "update-info" | "media-types";
|
||||
|
||||
export function createLegacyStore() {
|
||||
const sync = ref<any>(null);
|
||||
const documents = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
|
||||
const drafts = 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: [] } },
|
||||
});
|
||||
|
||||
return { sync, documents, drafts };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
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 uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
channel: "stable",
|
||||
notes: "",
|
||||
updateManifest: true,
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
loadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
status: "",
|
||||
});
|
||||
|
||||
return { releases, notices, selectedNotice, noticeDraft, uploadDraft };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createSourceStore() {
|
||||
const sources = ref<any>({ categories: [] });
|
||||
const endpoints = ref<any[]>([]);
|
||||
const draft = 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\"]",
|
||||
});
|
||||
|
||||
return { sources, endpoints, draft };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { reactive, ref } from "vue";
|
||||
|
||||
export function createSystemStore() {
|
||||
const database = ref<any>(null);
|
||||
const databaseLastSync = ref<any>(null);
|
||||
const healthSnapshot = ref<any>(null);
|
||||
const auditLogs = ref<any[]>([]);
|
||||
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
|
||||
const legacySyncMode = ref<"preview" | "run">("preview");
|
||||
|
||||
return { database, databaseLastSync, healthSnapshot, auditLogs, databaseForm, legacySyncMode };
|
||||
}
|
||||
@@ -24,8 +24,8 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; overflow-x: hidden; }
|
||||
body { margin: 0; background: var(--bg); overflow-x: hidden; }
|
||||
html { min-width: 320px; max-width: 100%; overflow-x: clip; }
|
||||
body { margin: 0; background: var(--bg); max-width: 100%; overflow-x: clip; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.65; }
|
||||
@@ -166,13 +166,18 @@ input:focus, textarea:focus, select:focus {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
min-width: 0;
|
||||
max-width: 260px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand strong { display: block; }
|
||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; overflow-x: hidden; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 5px; }
|
||||
.brand > div { min-width: 0; overflow: hidden; }
|
||||
.brand strong, .brand small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
|
||||
.nav-group { display: flex; flex-direction: column; gap: 5px; min-width: 0; overflow-x: hidden; }
|
||||
.nav-group p {
|
||||
margin: 0 0 2px;
|
||||
color: var(--muted);
|
||||
@@ -193,6 +198,11 @@ input:focus, textarea:focus, select:focus {
|
||||
color: #526070;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.nav-group button svg, .logout svg { flex: 0 0 auto; }
|
||||
.nav-group button span, .logout span {
|
||||
@@ -201,7 +211,7 @@ input:focus, textarea:focus, select:focus {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-group button:hover, .logout:hover { transform: translateX(2px); background: #eef4ff; color: var(--primary-dark); }
|
||||
.nav-group button:hover, .logout:hover { background: #eef4ff; 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; }
|
||||
|
||||
@@ -219,10 +229,10 @@ input:focus, textarea:focus, select:focus {
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
.metric, .panel, .quick-grid button, .revision-list button, .nested-card {
|
||||
.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, .quick-grid button:hover, .revision-list button:hover, .nested-card:hover {
|
||||
.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);
|
||||
}
|
||||
@@ -233,23 +243,6 @@ input:focus, textarea:focus, select:focus {
|
||||
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.chart-panel { min-height: 330px; display: flex; flex-direction: column; }
|
||||
.chart { min-height: 260px; width: 100%; flex: 1; }
|
||||
.quick-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
.quick-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.quick-grid button {
|
||||
min-height: 112px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
.quick-grid button:hover { border-color: var(--primary); background: #f8fbff; }
|
||||
.quick-grid svg { color: var(--primary); }
|
||||
.quick-grid span { color: var(--muted); line-height: 1.45; font-size: 13px; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
|
||||
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
|
||||
|
||||
@@ -344,6 +337,44 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.kv-grid { display: grid; grid-template-columns: 140px minmax(0, 1fr); gap: 11px 14px; }
|
||||
.kv-grid span { color: var(--muted); }
|
||||
.kv-grid strong { overflow-wrap: anywhere; }
|
||||
.sync-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.sync-summary div {
|
||||
min-height: 74px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px;
|
||||
}
|
||||
.sync-summary span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.sync-summary strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 18px;
|
||||
}
|
||||
.ops-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
border: 1px solid #f4d38c;
|
||||
border-radius: 10px;
|
||||
background: var(--warn-bg);
|
||||
color: #7a3b00;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.ops-note svg { flex: 0 0 auto; margin-top: 3px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -386,12 +417,47 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
background: #fff;
|
||||
}
|
||||
.upload-card {
|
||||
background: linear-gradient(135deg, #ffffff, #f8fbff);
|
||||
background: #fff;
|
||||
}
|
||||
.upload-progress {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.upload-progress-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.upload-progress-head strong { color: var(--ink); }
|
||||
.upload-progress-head span { color: var(--primary-dark); font-weight: 900; }
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 9px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.progress-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--primary);
|
||||
transition: width 0.18s var(--ease);
|
||||
}
|
||||
.upload-progress small {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid, .split, .split.wide-split { grid-template-columns: 1fr; }
|
||||
.chart-grid, .split, .split.wide-split, .sync-summary { grid-template-columns: 1fr; }
|
||||
.detail-panel { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
@@ -403,7 +469,6 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.metric-grid, .two-col { grid-template-columns: 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.quick-grid { grid-template-columns: 1fr; }
|
||||
.captcha-row { grid-template-columns: 1fr; }
|
||||
table { min-width: 720px; }
|
||||
.panel { overflow-x: auto; max-width: 100%; }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
|
||||
<table>
|
||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.auditLogs" :key="item.id">
|
||||
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</td>
|
||||
<td>{{ item.ip || "-" }}</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -47,17 +47,6 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel quick-panel">
|
||||
<div class="section-head"><h2>功能总览</h2><span class="badge">{{ ctx.quickActions.length }} 个入口</span></div>
|
||||
<div class="quick-grid">
|
||||
<button v-for="item in ctx.quickActions" :key="item.path" @click="ctx.navigate(item.path)">
|
||||
<component :is="item.icon" :size="18" />
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-panel"><h2>接口心跳延迟</h2><VChart class="chart" :option="ctx.heartbeatOption" autoresize /></section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>数据库运行状态</h2><span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span></div>
|
||||
<div class="kv-grid">
|
||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="section-head"><h2>旧项目同步</h2><button class="btn ghost" @click="ctx.previewLegacySync">预览</button></div>
|
||||
<pre class="json-preview small">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
||||
</section>
|
||||
<aside class="panel editor-panel">
|
||||
<h2>连接与同步</h2>
|
||||
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
|
||||
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
|
||||
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
|
||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel page-stack">
|
||||
<h2>健康快照</h2>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
@@ -24,6 +24,10 @@ defineProps<{ ctx: any }>();
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
|
||||
</div>
|
||||
|
||||
<p v-if="ctx.activeLegacyName === 'media-types'" class="notice">
|
||||
生产环境不再自动依赖旧项目路径。需要以 server/update/public/media-types.json 为基板时,请切换到 Raw JSON 粘贴完整内容,校验通过后保存发布。
|
||||
</p>
|
||||
|
||||
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
|
||||
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
|
||||
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
|
||||
|
||||
@@ -25,7 +25,22 @@ defineProps<{ ctx: any }>();
|
||||
<label class="wide">发布说明<textarea v-model="ctx.uploadDraft.notes" rows="3"></textarea></label>
|
||||
<label class="checkbox wide"><input v-model="ctx.uploadDraft.updateManifest" type="checkbox" />上传后同步更新兼容 update-info.json</label>
|
||||
</div>
|
||||
<button class="btn primary" @click="ctx.uploadPackage"><UploadCloud :size="16" />上传发布包</button>
|
||||
<div v-if="ctx.uploadDraft.file || ctx.uploadDraft.uploading || ctx.uploadDraft.status" class="upload-progress">
|
||||
<div class="upload-progress-head">
|
||||
<strong>{{ ctx.uploadDraft.status || "等待上传" }}</strong>
|
||||
<span>{{ ctx.uploadDraft.progress || 0 }}%</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<span :style="{ width: `${ctx.uploadDraft.progress || 0}%` }"></span>
|
||||
</div>
|
||||
<small>
|
||||
{{ ctx.uploadDraft.file?.name || "发布包" }}
|
||||
<template v-if="ctx.uploadDraft.totalBytes">
|
||||
· {{ ctx.formatBytes(ctx.uploadDraft.loadedBytes || 0) }} / {{ ctx.formatBytes(ctx.uploadDraft.totalBytes || 0) }}
|
||||
</template>
|
||||
</small>
|
||||
</div>
|
||||
<button class="btn primary" :disabled="ctx.uploadDraft.uploading" @click="ctx.uploadPackage"><UploadCloud :size="16" />{{ ctx.uploadDraft.uploading ? "上传中" : "上传发布包" }}</button>
|
||||
</section>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel editor-panel">
|
||||
<h2>修改后台密码</h2>
|
||||
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" /></label>
|
||||
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" /></label>
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>旧项目同步预览</h2><button class="btn ghost" @click="ctx.previewLegacySync">刷新预览</button></div>
|
||||
<pre class="json-preview">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行同步</button>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, KeyRound, ListChecks, RefreshCw, ShieldCheck } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
const tabs = [
|
||||
{ id: "database", label: "数据库", icon: Database },
|
||||
{ id: "sync", label: "旧项目同步", icon: RefreshCw },
|
||||
{ id: "security", label: "安全设置", icon: ShieldCheck },
|
||||
{ id: "health", label: "健康快照", icon: Activity },
|
||||
{ id: "audit", label: "审计日志", icon: ListChecks },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<nav class="tabs" aria-label="系统运维标签">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
:class="{ active: ctx.systemTab === tab.id }"
|
||||
@click="ctx.setSystemTab(tab.id)"
|
||||
>
|
||||
<component :is="tab.icon" :size="15" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section v-if="ctx.systemTab === 'database'" class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<h2>数据库运行状态</h2>
|
||||
<span :class="['badge', ctx.statusTone(ctx.database?.activeProvider)]">{{ ctx.database?.activeProvider || "-" }}</span>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="sync-summary">
|
||||
<div>
|
||||
<span><ArrowDownUp :size="15" />最近同步方向</span>
|
||||
<strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span><ListChecks :size="15" />影响记录</span>
|
||||
<strong>{{ ctx.databaseSyncTableCount(ctx.databaseLastSync) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span><Clock3 :size="15" />完成时间</span>
|
||||
<strong>{{ ctx.databaseLastSync?.finishedAt || ctx.database?.lastSyncAt || "-" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-note">
|
||||
<AlertTriangle :size="16" />
|
||||
<span>数据库同步是覆盖式全表 upsert。执行前确认方向:SQLite 导入远端会以本地库为源,远端同步回本地会以 MySQL 为源。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel editor-panel">
|
||||
<h2>连接与同步</h2>
|
||||
<label>Provider<select v-model="ctx.databaseForm.provider"><option>sqlite</option><option>mysql</option></select></label>
|
||||
<label>SQLite 路径<input v-model="ctx.databaseForm.sqlitePath" placeholder="留空使用当前配置" /></label>
|
||||
<label>MySQL DSN<input v-model="ctx.databaseForm.mysqlDsn" placeholder="user:pass@tcp(host:3306)/db?parseTime=true" /></label>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||
<button class="btn primary" @click="ctx.syncDatabase('import')">SQLite 导入远端</button>
|
||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">远端同步回本地</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'sync'" class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>旧项目同步</h2>
|
||||
<p class="muted">预览只检查旧目录和影响范围;执行会先备份当前发布目录,再复制旧项目数据并导入反馈记录。</p>
|
||||
</div>
|
||||
<button class="btn ghost" @click="ctx.previewLegacySync"><RefreshCw :size="16" />刷新预览</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-summary">
|
||||
<div>
|
||||
<span>当前模式</span>
|
||||
<strong>{{ ctx.legacySyncMode === "run" || ctx.legacySync?.dryRun === false ? "执行结果" : "Dry-run 预览" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>状态</span>
|
||||
<strong>{{ ctx.legacySync?.ok === false ? "存在错误" : "可继续" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>完成时间</span>
|
||||
<strong>{{ ctx.legacySync?.finishedAt || "-" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-grid">
|
||||
<span>复制文件</span><strong>{{ ctx.legacySync?.stats?.copiedFiles || 0 }}</strong>
|
||||
<span>复制目录</span><strong>{{ ctx.legacySync?.stats?.copiedDirectories || 0 }}</strong>
|
||||
<span>导入记录</span><strong>{{ ctx.legacySync?.stats?.importedRows || 0 }}</strong>
|
||||
<span>缺失路径</span><strong>{{ ctx.legacySync?.stats?.missingPaths || 0 }}</strong>
|
||||
</div>
|
||||
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacySync) }}</pre>
|
||||
<div class="button-row">
|
||||
<button class="btn primary" @click="ctx.runLegacySync">执行旧项目同步</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'security'" class="split">
|
||||
<section class="panel editor-panel">
|
||||
<h2>修改后台密码</h2>
|
||||
<label>当前密码<input v-model="ctx.passwordForm.currentPassword" type="password" autocomplete="current-password" /></label>
|
||||
<label>新密码<input v-model="ctx.passwordForm.newPassword" type="password" autocomplete="new-password" /></label>
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>当前安全策略</h2><span class="badge good">已启用</span></div>
|
||||
<div class="kv-grid">
|
||||
<span>登录保护</span><strong>验证码 + 连续失败限流</strong>
|
||||
<span>写操作保护</span><strong>HttpOnly Session + CSRF Token</strong>
|
||||
<span>Cookie</span><strong>HTTPS 或 X-Forwarded-Proto=https 时自动 Secure</strong>
|
||||
<span>会话范围</span><strong>后台 API 与 SSE 事件流均要求登录</strong>
|
||||
<span>密码规则</span><strong>至少 8 位,不能为 admin,不能与当前密码相同</strong>
|
||||
<span>兼容哈希</span><strong>保留 SHA-256 登录兼容,后续可平滑迁移到更强算法</strong>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.systemTab === 'health'" class="panel page-stack">
|
||||
<div class="section-head"><h2>健康快照</h2><span class="badge neutral">只读</span></div>
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||
</section>
|
||||
|
||||
<section v-else class="panel page-stack">
|
||||
<div class="section-head"><h2>审计日志</h2><button class="btn ghost" @click="ctx.loadAudit">刷新</button></div>
|
||||
<table>
|
||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.auditLogs" :key="item.id">
|
||||
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</td>
|
||||
<td>{{ item.ip || "-" }}</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.auditLogs.length === 0"><td colspan="5">暂无审计日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -4,9 +4,21 @@ import vue from "@vitejs/plugin-vue";
|
||||
export default defineConfig({
|
||||
base: "/admin/",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 650,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ["vue", "vue-router"],
|
||||
charts: ["echarts", "vue-echarts"],
|
||||
icons: ["lucide-vue-next"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:33550"
|
||||
}
|
||||
}
|
||||
"/api": "http://127.0.0.1:33550",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user