继续更新 update 门户站点界面和功能
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 20:17:34 +08:00
parent f525e5f3ba
commit 2513eb2903
68 changed files with 5586 additions and 3195 deletions
+133 -111
View File
@@ -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>