Files
YMhut-box-C-/server/unified-management/web/admin/src/App.vue
T
QWQLwToo 962a2f2143
build-winui / winui (push) Waiting to run
更新 update 门户站点界面和后台功能
2026-06-27 18:09:11 +08:00

1512 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDownToLine,
ClipboardList,
Code2,
Database,
FileJson,
LayoutDashboard,
LogOut,
MessageSquareText,
Network,
RefreshCw,
ShieldCheck,
} from "lucide-vue-next";
import EndpointsView from "./views/EndpointsView.vue";
import FeedbacksView from "./views/FeedbacksView.vue";
import LegacyJsonView from "./views/LegacyJsonView.vue";
import ReleasesView from "./views/ReleasesView.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";
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "audit";
type ToastState = { message: string; type: "success" | "warn" | "error" };
type LoadSystemOptions = { preserveForms?: boolean };
type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean };
type LoadMailOptions = { preserveForm?: boolean };
type Captcha = {
captchaId: string;
image: string;
};
type AuthBootstrap = {
isDefaultPassword: boolean;
defaultUsername: string;
defaultPassword: string;
};
type RouteItem = {
path: string;
label: string;
description: string;
icon: any;
};
const route = useRoute();
const router = useRouter();
const currentPath = computed(() => normalizeAdminPath(route.path));
const loading = ref(false);
const toast = ref<ToastState | null>(null);
const autoRefreshPaused = ref(false);
const databaseFormEditing = ref(false);
const mailConfigEditing = ref(false);
let refreshTimer: number | undefined;
let toastTimer: number | undefined;
let events: EventSource | null = null;
const authStore = createAuthStore();
const dashboardStore = createDashboardStore();
const feedbackStore = createFeedbackStore();
const releaseStore = createReleaseStore();
const legacyStore = createLegacyStore();
const sourceStore = createSourceStore();
const systemStore = createSystemStore();
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, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
const { sources, endpoints, draft: sourceDraft } = sourceStore;
const { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
const routes: RouteItem[] = [
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
{ path: "/admin/feedbacks", label: "反馈工单", description: "旧客户端反馈与处理流转", icon: MessageSquareText },
{ path: "/admin/releases", label: "发布与日志", description: "发布包、版本公告和兼容日志", icon: ArrowDownToLine },
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "可视化维护 update-info.json", icon: FileJson },
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "维护旧客户端媒体源结构", icon: ClipboardList },
{ path: "/admin/sources", label: "来源目录", description: "媒体/数据源目录和健康检测", icon: Network },
{ path: "/admin/endpoints", label: "客户端接口", description: "新版客户端动态接口配置", icon: Code2 },
{ path: "/admin/system", label: "系统运维", description: "数据库、旧项目同步、安全、健康与审计", icon: Database },
];
const navGroups = [
{ label: "概览", items: routes.filter((item) => ["/admin/dashboard"].includes(item.path)) },
{ label: "反馈", items: routes.filter((item) => ["/admin/feedbacks"].includes(item.path)) },
{ label: "发布与兼容", items: routes.filter((item) => ["/admin/releases", "/admin/legacy/update-info", "/admin/legacy/media-types"].includes(item.path)) },
{ label: "客户端接口", items: routes.filter((item) => ["/admin/sources", "/admin/endpoints"].includes(item.path)) },
{ label: "系统运维", items: routes.filter((item) => ["/admin/system"].includes(item.path)) },
];
const pageMeta = computed(() => routes.find((item) => item.path === currentPath.value) || routes[0]);
const activeLegacyName = computed<LegacyName | null>(() => {
if (currentPath.value.endsWith("/update-info")) return "update-info";
if (currentPath.value.endsWith("/media-types")) return "media-types";
return null;
});
const kpis = computed(() => dashboard.value?.kpis || {});
const sourceHealth = computed(() => dashboard.value?.sourceHealth || {});
const feedbackStatus = computed(() => dashboard.value?.feedbackStatus || {});
const heartbeats = computed(() => dashboard.value?.heartbeats || []);
const clientCalls = computed(() => dashboard.value?.clientCalls || []);
const releasePackages = computed(() => releases.value?.packages || []);
const sourceCategories = computed(() => sources.value?.categories || []);
const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length);
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
const latestNotice = computed(() => releaseNotices.value[0] || null);
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
const activeMediaCategory = computed(() => {
const categories = legacyDrafts["media-types"].form.categories || [];
return categories[activeMediaCategoryIndex.value] || null;
});
const systemTab = computed<SystemTab>(() => normalizeSystemTab(route.query.tab));
const heartbeatChartRows = computed(() => {
const rows = heartbeats.value
.slice()
.reverse()
.map((item: any) => ({
time: heartbeatTimeValue(item.checkedAt),
label: timeLabel(item.checkedAt),
latency: Number(item.latencyMs ?? item.latency_ms ?? 0),
name: item.name || item.sourceId || "未知接口",
status: labelStatus(item.status),
}))
.filter((item: any) => Number.isFinite(item.latency));
return rows.length ? rows : [{ time: Date.now(), label: "暂无", latency: 0, name: "暂无检测记录", status: "未检测" }];
});
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
const heartbeatOption = computed(() => ({
animation: true,
tooltip: { trigger: "axis" },
grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true },
xAxis: {
type: "category",
boundaryGap: heartbeatChartRows.value.length <= 1,
data: heartbeatChartRows.value.map((item: any) => item.label),
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisLabel: { color: "#64748b" },
},
yAxis: {
type: "value",
name: "ms",
min: 0,
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisLabel: { color: "#64748b" },
splitLine: { lineStyle: { color: "#e5e7eb" } },
},
series: [
{
name: "接口延迟",
type: "line",
smooth: true,
showSymbol: true,
symbolSize: 7,
connectNulls: true,
areaStyle: { opacity: 0.18 },
data: heartbeatChartRows.value.map((item: any) => item.latency),
color: "#2563eb",
lineStyle: { width: 3 },
emphasis: { focus: "series" },
},
],
}));
const healthOption = computed(() => {
const data = healthStatusOrder.map((item) => ({
name: item.label,
value: Number(sourceHealth.value?.[item.key] || 0),
itemStyle: { color: item.color },
})).filter((item) => item.value > 0);
return {
tooltip: { trigger: "item" },
legend: { bottom: 0 },
series: [
{
name: "接口健康",
type: "pie",
radius: ["48%", "72%"],
data: data.length ? data : [{ name: "暂无数据", value: 1, itemStyle: { color: "#cbd5e1" } }],
},
],
};
});
const feedbackOption = computed(() => ({
tooltip: { trigger: "axis" },
grid: { left: 34, right: 12, top: 20, bottom: 28 },
xAxis: { type: "category", data: objectEntries(feedbackStatus.value).map((item) => item.name) },
yAxis: { type: "value", splitLine: { lineStyle: { color: "#e5e7eb" } } },
series: [{ name: "工单", type: "bar", data: objectEntries(feedbackStatus.value).map((item) => item.value), color: "#0f766e" }],
}));
const availabilityOption = computed(() => {
const total = Number(kpis.value.sourceTotal || 0);
const ok = Number(sourceHealth.value.ok || 0) + Number(sourceHealth.value.redirected || 0);
const value = total ? Math.round((ok / total) * 100) : 0;
return {
series: [
{
type: "gauge",
progress: { show: true, width: 12 },
axisLine: { lineStyle: { width: 12 } },
axisLabel: { distance: 16 },
pointer: { width: 4 },
detail: { formatter: "{value}%", fontSize: 24 },
data: [{ value, name: "可用率" }],
},
],
};
});
const healthStatusOrder = [
{ key: "ok", label: "正常", color: "#16a34a" },
{ key: "redirected", label: "重定向健康", color: "#f59e0b" },
{ key: "degraded", label: "降级", color: "#d97706" },
{ key: "error", label: "错误", color: "#dc2626" },
{ key: "unknown", label: "未知", color: "#94a3b8" },
];
const viewContext = computed(() => ({
activeLegacyLabel: activeLegacyLabel.value,
activeLegacyName: activeLegacyName.value,
addFeedbackComment,
addMediaCategory,
addMediaSubcategory,
addUpdateMirror,
applyLegacyModal,
auditPage,
auditLogs: auditLogs.value,
autoRefreshPaused: autoRefreshPaused.value,
availabilityOption: availabilityOption.value,
branding,
changePassword,
checkSources,
clientCalls: clientCalls.value,
commentDraft,
copyEndpointToSource,
database: database.value,
databaseConfig: databaseConfig.value,
databaseConfigCollapsed: databaseConfigCollapsed.value,
databaseFormEditing: databaseFormEditing.value,
databaseForm,
databaseLastSync: databaseLastSync.value,
databaseSyncStatusLabel,
databaseSyncDirectionLabel,
databaseSyncTableCount,
databaseConfigSummary,
deleteEndpoint,
editDatabaseConfig,
endpointStatus,
endpoints: endpoints.value,
feedbackFilters,
feedbackOption: feedbackOption.value,
feedbackPage: feedbackPage.value,
feedbackUpdate,
formatBytes,
healthOption: healthOption.value,
healthSnapshot: healthSnapshot.value,
healthyEndpointCount: healthyEndpointCount.value,
heartbeatOption: heartbeatOption.value,
heartbeats: heartbeats.value,
isHeartbeatChartEmpty: isHeartbeatChartEmpty.value,
importNotices,
kpis: kpis.value,
labelStatus,
labelPriority,
latestNotice: latestNotice.value,
legacyDocuments,
legacyDrafts,
legacyModal,
activeMediaCategoryIndex: activeMediaCategoryIndex.value,
activeMediaCategory: activeMediaCategory.value,
legacySync: legacySync.value,
legacySyncMode: legacySyncMode.value,
loadAudit,
loadBranding,
loadFeedbacks,
loadMigrationStatus,
mailConfig,
mailConfigEditing: mailConfigEditing.value,
markDatabaseFormEditing,
markMailConfigEditing,
migrationStatus: migrationStatus.value,
loadMailConfig,
reloadDatabaseConfig,
reloadMailConfig,
saveDatabase,
saveBranding,
saveMailConfig,
testMail,
retryFeedbackMail,
navigate,
noticeDraft,
onPackageSelected,
openFeedback,
openNotice,
passwordForm,
pretty,
previewLegacySync,
removeItem,
releaseNotices: releaseNotices.value,
releasePackages: releasePackages.value,
releases: releases.value,
restoreLegacy,
restoreNotice,
runLegacySync,
saveFeedbackUpdate,
saveLegacy,
saveNotice,
saveSource,
selectedFeedback: selectedFeedback.value,
selectedNotice: selectedNotice.value,
sourceCategories: sourceCategories.value,
sourceCheckJobs: sourceCheckJobs.value,
sourceDraft,
statusTone,
syncDatabase,
systemTab: systemTab.value,
setSystemTab,
setAuditPage,
selectAuditLog,
testDatabase,
toggleAutoRefresh,
openMediaCategoryModal,
openMediaSubcategoryModal,
openUpdateMirrorModal,
selectMediaCategory,
closeLegacyModal,
updateLegacyRawFromForm,
uploadDraft,
uploadPackage,
auditMessage,
auditTypeLabel,
validateLegacy,
validateNotice,
visibleEndpointCount: visibleEndpointCount.value,
}));
async function api<T>(target: string, init: RequestInit = {}): Promise<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 === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
return "database";
}
function navigate(next: string) {
if (currentPath.value === next) {
void load();
return;
}
void router.push(next);
}
function setSystemTab(tab: SystemTab) {
void router.replace({ path: "/admin/system", query: tab === "database" ? {} : { tab } });
}
function toggleAutoRefresh() {
autoRefreshPaused.value = !autoRefreshPaused.value;
}
function setToast(message: string, type: ToastState["type"] = "success") {
toast.value = { message, type };
if (toastTimer) window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
if (toast.value?.message === message) toast.value = null;
}, 2500);
}
async function guarded(task: () => Promise<void>) {
loading.value = true;
try {
await task();
} catch (error) {
const rawMessage = error instanceof Error ? error.message : String(error);
const message = toChineseError(rawMessage);
setToast(message, "error");
if (isAuthError(rawMessage, message)) {
csrf.value = "";
sessionStorage.removeItem("ymhut.csrf");
localStorage.removeItem("ymhut.csrf");
events?.close();
events = null;
navigate("/admin/login");
}
} finally {
loading.value = false;
}
}
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");
}
async function loadAuthBootstrap() {
authBootstrap.value = await api<AuthBootstrap>("/api/admin/auth/bootstrap");
}
async function login() {
await guarded(async () => {
const data = await api<{ csrfToken: string }>("/api/admin/auth/login", {
method: "POST",
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
});
csrf.value = data.csrfToken;
sessionStorage.setItem("ymhut.csrf", csrf.value);
localStorage.removeItem("ymhut.csrf");
connectAdminEvents();
navigate("/admin/dashboard");
});
}
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;
navigate("/admin/login");
}
async function load() {
await guarded(async () => {
if (currentPath.value === "/admin/login") {
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/system") await loadSystem({ preserveForms: true });
const legacyName = activeLegacyName.value;
if (legacyName) await loadLegacy(legacyName);
connectAdminEvents();
});
}
async function loadDashboard() {
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
}
async function loadSystem(options: LoadSystemOptions = {}) {
await Promise.all([
loadDatabase({ preserveForm: options.preserveForms }),
loadMailConfig({ preserveForm: options.preserveForms }),
loadHealth(),
loadAudit(),
loadMigrationStatus(),
loadBranding(),
]);
}
async function loadFeedbacks() {
const params = new URLSearchParams({ page: String(feedbackFilters.page), perPage: String(feedbackFilters.perPage) });
if (feedbackFilters.q) params.set("q", feedbackFilters.q);
if (feedbackFilters.status) params.set("status", feedbackFilters.status);
if (feedbackFilters.priority) params.set("priority", feedbackFilters.priority);
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
}
async function openFeedback(item: any) {
const data = await api<{ feedback: any }>(`/api/admin/feedbacks/${encodeURIComponent(item.code)}`);
selectedFeedback.value = data.feedback;
feedbackUpdate.status = data.feedback.status || "new";
feedbackUpdate.priority = data.feedback.priority || "normal";
feedbackUpdate.statusDetail = data.feedback.statusDetail || "";
feedbackUpdate.publicReply = data.feedback.publicReply || "";
}
async function saveFeedbackUpdate() {
if (!selectedFeedback.value) return;
await guarded(async () => {
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}`, { method: "PATCH", body: JSON.stringify(feedbackUpdate) });
setToast("反馈工单已更新");
await openFeedback(selectedFeedback.value);
await loadFeedbacks();
});
}
async function addFeedbackComment() {
if (!selectedFeedback.value || !commentDraft.body.trim()) return;
await guarded(async () => {
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}/comments`, {
method: "POST",
body: JSON.stringify({ author: "admin", body: commentDraft.body, internal: commentDraft.internal }),
});
commentDraft.body = "";
await openFeedback(selectedFeedback.value);
});
}
async function loadReleases(preferredVersion = noticeDraft.version) {
const [releaseData, noticeData] = await Promise.all([
api<{ manifest: any }>("/api/admin/releases"),
api<{ items: any[] }>("/api/admin/releases/notices"),
]);
releases.value = releaseData.manifest;
releaseNotices.value = noticeData.items || [];
const target = preferredVersion && releaseNotices.value.some((item: any) => item.version === preferredVersion)
? preferredVersion
: releaseNotices.value[0]?.version;
if (target && noticeDraft.version !== target) await openNotice(target);
}
async function importNotices() {
await guarded(async () => {
const data = await api<{ items: any[] }>("/api/admin/releases/notices/import", { method: "POST", body: "{}" });
releaseNotices.value = data.items || [];
setToast("版本日志已从目录重新导入");
});
}
async function openNotice(version: string) {
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(version)}`);
selectedNotice.value = data.document;
noticeDraft.version = version;
noticeDraft.raw = data.document.raw || "";
noticeDraft.preview = data.document.parsed || null;
}
async function validateNotice() {
if (!noticeDraft.version) return;
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}/validate`, {
method: "POST",
body: JSON.stringify({ raw: noticeDraft.raw }),
});
noticeDraft.raw = data.document.raw;
noticeDraft.preview = data.document.parsed;
setToast("版本日志 JSON 校验通过");
}
async function saveNotice() {
if (!noticeDraft.version) return;
await guarded(async () => {
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}`, {
method: "PUT",
body: JSON.stringify({ raw: noticeDraft.raw, note: noticeDraft.note }),
});
selectedNotice.value = data.document;
noticeDraft.note = "";
setToast("版本日志已保存并同步兼容更新信息");
await loadReleases(data.document?.notice?.version || noticeDraft.version);
});
}
async function restoreNotice(revisionId: number) {
if (!noticeDraft.version) return;
await guarded(async () => {
const data = await api<{ document: any }>(`/api/admin/releases/notices/${encodeURIComponent(noticeDraft.version)}/restore`, {
method: "POST",
body: JSON.stringify({ revisionId }),
});
selectedNotice.value = data.document;
noticeDraft.raw = data.document.raw || "";
noticeDraft.preview = data.document.parsed || null;
setToast("版本日志已恢复");
});
}
async function loadLegacy(name: LegacyName) {
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`);
legacyDocuments[name] = data.document;
legacyDrafts[name].raw = data.document.raw || "";
legacyDrafts[name].preview = data.document.parsed || null;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
if (name === "media-types") clampMediaCategoryIndex();
}
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;
}
}
async function uploadPackage() {
if (!uploadDraft.file) {
setToast("请选择要上传的发布包", "warn");
return;
}
await guarded(async () => {
const form = new FormData();
form.append("file", uploadDraft.file as File);
form.append("version", uploadDraft.version);
form.append("platform", uploadDraft.platform);
form.append("arch", uploadDraft.arch);
form.append("channel", uploadDraft.channel);
form.append("notes", uploadDraft.notes);
form.append("updateManifest", String(uploadDraft.updateManifest));
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;
});
}
async function validateLegacy(name: LegacyName) {
const data = await api<{ document: any }>(`/api/admin/legacy/${name}/validate`, {
method: "POST",
body: JSON.stringify({ raw: legacyDrafts[name].raw }),
});
legacyDrafts[name].raw = data.document.raw;
legacyDrafts[name].preview = data.document.parsed;
setToast("兼容 JSON 校验通过");
}
async function saveLegacy(name: LegacyName) {
await guarded(async () => {
if (legacyDrafts[name].tab === "form") updateLegacyRawFromForm(name);
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`, {
method: "PUT",
body: JSON.stringify({ raw: legacyDrafts[name].raw, note: legacyDrafts[name].note }),
});
legacyDocuments[name] = data.document;
legacyDrafts[name].raw = data.document.raw;
legacyDrafts[name].preview = data.document.parsed;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
legacyDrafts[name].note = "";
if (name === "media-types") {
clampMediaCategoryIndex();
}
if (name === "update-info") {
await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version);
}
setToast("兼容 JSON 已保存并发布到旧路径");
});
}
async function restoreLegacy(name: LegacyName, revisionId: number) {
await guarded(async () => {
const data = await api<{ document: any }>(`/api/admin/legacy/${name}/restore`, {
method: "POST",
body: JSON.stringify({ revisionId }),
});
legacyDocuments[name] = data.document;
legacyDrafts[name].raw = data.document.raw;
legacyDrafts[name].preview = data.document.parsed;
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
if (name === "media-types") clampMediaCategoryIndex();
if (name === "update-info") await loadReleases(legacyDrafts[name].preview?.app_version || legacyDrafts[name].preview?.version || noticeDraft.version);
setToast("兼容 JSON 已恢复");
});
}
function makeLegacyForm(name: LegacyName, parsed: any) {
if (name === "media-types") {
return {
layout_version: parsed.layout_version || "1.0.0",
last_updated: parsed.last_updated || "",
ui_config: JSON.stringify(parsed.ui_config || {}, null, 2),
categories: clone(parsed.categories || []).map((cat: any) => ({
id: cat.id || "",
name: cat.name || "",
enabled: cat.enabled !== false,
subcategories: clone(cat.subcategories || []).map((sub: any) => ({
id: sub.id || "",
name: sub.name || "",
description: sub.description || "",
api_url: sub.api_url || "",
thumbnail_url: sub.thumbnail_url || "",
refresh_interval: Number(sub.refresh_interval || 300),
supported_formats: Array.isArray(sub.supported_formats) ? sub.supported_formats.join(", ") : "",
downloadable: sub.downloadable !== false,
})),
})),
};
}
return {
app_version: parsed.app_version || parsed.version || "",
title: parsed.title || "",
message: parsed.message || "",
message_md: parsed.message_md || "",
download_url: parsed.download_url || "",
release_notes: parsed.release_notes || "",
release_notes_md: parsed.release_notes_md || "",
update_notes: JSON.stringify(parsed.update_notes || {}, null, 2),
last_update_notes: JSON.stringify(parsed.last_update_notes || {}, null, 2),
download_mirrors: clone(parsed.download_mirrors || []),
package_sha256: parsed.package_sha256 || "",
package_size: parsed.package_size || "",
updated_at: parsed.updated_at || parsed.last_updated || "",
};
}
function updateLegacyRawFromForm(name: LegacyName) {
const current = parseJSONSafe(legacyDrafts[name].raw, legacyDrafts[name].preview || {});
const form = legacyDrafts[name].form || {};
if (name === "media-types") {
current.layout_version = form.layout_version || "1.0.0";
current.last_updated = form.last_updated || new Date().toISOString();
current.ui_config = parseJSONSafe(form.ui_config, current.ui_config || {});
current.categories = (form.categories || []).map((cat: any) => ({
...(findByID(current.categories, cat.id) || {}),
id: cat.id,
name: cat.name,
enabled: cat.enabled !== false,
subcategories: (cat.subcategories || []).map((sub: any) => ({
...(findByID((findByID(current.categories, cat.id) || {}).subcategories, sub.id) || {}),
id: sub.id,
name: sub.name,
description: sub.description,
api_url: sub.api_url,
thumbnail_url: sub.thumbnail_url,
refresh_interval: Number(sub.refresh_interval || 300),
supported_formats: splitList(sub.supported_formats),
downloadable: sub.downloadable !== false,
})),
}));
} else {
for (const key of ["app_version", "title", "message", "message_md", "download_url", "release_notes", "release_notes_md", "package_sha256", "updated_at"]) {
if (form[key] !== undefined) current[key] = form[key];
}
if (form.package_size !== "") current.package_size = Number(form.package_size || 0);
current.download_mirrors = clone(form.download_mirrors || current.download_mirrors || []);
current.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {});
current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_update_notes || {});
}
legacyDrafts[name].raw = JSON.stringify(current, null, 2) + "\n";
legacyDrafts[name].preview = current;
}
function addUpdateMirror() {
const doc = parseJSONSafe(legacyDrafts["update-info"].raw, legacyDrafts["update-info"].preview || {});
const mirrors = Array.isArray(doc.download_mirrors) ? doc.download_mirrors : [];
mirrors.push({ id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true });
doc.download_mirrors = mirrors;
legacyDrafts["update-info"].raw = JSON.stringify(doc, null, 2) + "\n";
legacyDrafts["update-info"].preview = doc;
}
function addMediaCategory(name: LegacyName) {
const form = legacyDrafts[name].form;
if (!Array.isArray(form.categories)) form.categories = [];
form.categories.push({ id: `category-${form.categories.length + 1}`, name: "新分类", enabled: true, subcategories: [] });
}
function addMediaSubcategory(category: any) {
if (!Array.isArray(category.subcategories)) category.subcategories = [];
category.subcategories.push({ id: `source-${category.subcategories.length + 1}`, name: "新接口", api_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true });
}
function openMediaCategoryModal(index = -1) {
const categories = legacyDrafts["media-types"].form.categories || [];
const existing = index >= 0 ? categories[index] : null;
Object.assign(legacyModal, {
open: true,
type: "media-category",
categoryIndex: index,
itemIndex: -1,
draft: clone(existing || { id: `category-${categories.length + 1}`, name: "新分类", enabled: true }),
});
}
function selectMediaCategory(index: number) {
activeMediaCategoryIndex.value = Math.max(0, index);
clampMediaCategoryIndex();
}
function clampMediaCategoryIndex() {
const categories = legacyDrafts["media-types"].form.categories || [];
if (categories.length === 0) {
activeMediaCategoryIndex.value = 0;
return;
}
activeMediaCategoryIndex.value = Math.min(Math.max(0, activeMediaCategoryIndex.value), categories.length - 1);
}
function openMediaSubcategoryModal(categoryIndex = activeMediaCategoryIndex.value, itemIndex = -1) {
const categories = legacyDrafts["media-types"].form.categories || [];
const category = categories[categoryIndex];
if (!category) return;
activeMediaCategoryIndex.value = categoryIndex;
const subcategories = category.subcategories || [];
const existing = itemIndex >= 0 ? subcategories[itemIndex] : null;
Object.assign(legacyModal, {
open: true,
type: "media-subcategory",
categoryIndex,
itemIndex,
draft: clone(existing || { id: `source-${subcategories.length + 1}`, name: "新接口", api_url: "", thumbnail_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true, description: "" }),
});
}
function openUpdateMirrorModal(index = -1) {
const form = legacyDrafts["update-info"].form;
if (!Array.isArray(form.download_mirrors)) form.download_mirrors = [];
const mirrors = form.download_mirrors;
const existing = index >= 0 ? mirrors[index] : null;
Object.assign(legacyModal, {
open: true,
type: "update-mirror",
categoryIndex: -1,
itemIndex: index,
draft: clone(existing || { id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true }),
});
}
function closeLegacyModal() {
Object.assign(legacyModal, { open: false, type: "", categoryIndex: -1, itemIndex: -1, draft: {} });
}
function applyLegacyModal() {
if (legacyModal.type === "media-category") {
const form = legacyDrafts["media-types"].form;
if (!Array.isArray(form.categories)) form.categories = [];
const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false, subcategories: legacyModal.draft.subcategories || [] };
if (legacyModal.categoryIndex >= 0) {
next.subcategories = form.categories[legacyModal.categoryIndex]?.subcategories || [];
form.categories.splice(legacyModal.categoryIndex, 1, next);
activeMediaCategoryIndex.value = legacyModal.categoryIndex;
} else {
form.categories.push(next);
activeMediaCategoryIndex.value = form.categories.length - 1;
}
clampMediaCategoryIndex();
}
if (legacyModal.type === "media-subcategory") {
const category = legacyDrafts["media-types"].form.categories?.[legacyModal.categoryIndex];
if (!category) return;
if (!Array.isArray(category.subcategories)) category.subcategories = [];
const next = { ...legacyModal.draft, refresh_interval: Number(legacyModal.draft.refresh_interval || 300), downloadable: legacyModal.draft.downloadable !== false };
if (legacyModal.itemIndex >= 0) category.subcategories.splice(legacyModal.itemIndex, 1, next);
else category.subcategories.push(next);
}
if (legacyModal.type === "update-mirror") {
const form = legacyDrafts["update-info"].form;
if (!Array.isArray(form.download_mirrors)) form.download_mirrors = [];
const mirrors = form.download_mirrors;
const next = { ...legacyModal.draft, enabled: legacyModal.draft.enabled !== false };
if (legacyModal.itemIndex >= 0) mirrors.splice(legacyModal.itemIndex, 1, next);
else mirrors.push(next);
updateLegacyRawFromForm("update-info");
}
closeLegacyModal();
}
function removeItem(list: any[], index: number) {
list.splice(index, 1);
clampMediaCategoryIndex();
}
async function loadSources() {
const data = await api<{ catalog: any }>("/api/admin/sources");
sources.value = data.catalog || { categories: [] };
}
async function saveSource() {
await guarded(async () => {
await api("/api/admin/sources", { method: "POST", body: JSON.stringify(sourceDraft) });
setToast("接口源已保存");
await Promise.all([loadSources(), loadEndpoints()]);
});
}
async function checkSources() {
await guarded(async () => {
const data = await api<{ jobId: string; job: any }>("/api/admin/sources/check", { method: "POST", body: "{}" });
if (data.job) sourceCheckJobs.value = [data.job, ...sourceCheckJobs.value.filter((item) => item.id !== data.job.id)].slice(0, 5);
setToast(`服务端接口检测已进入队列:${data.jobId}`);
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/system") await loadSystem({ preserveForms: true });
});
}
async function loadSourceCheckJobs() {
const data = await api<{ items: any[] }>("/api/admin/sources/check/status");
sourceCheckJobs.value = data.items || [];
}
async function loadEndpoints() {
const data = await api<{ items: any[] }>("/api/admin/endpoints");
endpoints.value = data.items || [];
}
function copyEndpointToSource(item: any) {
Object.assign(sourceDraft, {
sourceId: item.id || item.sourceId,
categoryId: item.category || item.categoryId || "custom",
categoryName: item.category || item.categoryName || "自定义接口",
name: item.name,
method: item.method || "GET",
apiUrl: item.urlTemplate || item.apiUrl || "",
urlTemplate: item.urlTemplate || item.apiUrl || "",
proxyMode: item.proxyMode || "client_direct",
enabled: item.enabled,
clientVisible: item.clientVisible,
cacheSeconds: item.cacheSeconds || 300,
checkIntervalSec: item.checkIntervalSec || item.cacheSeconds || 300,
supportedFormats: JSON.stringify(item.supportedFormats || ["json"]),
});
navigate("/admin/sources");
}
async function deleteEndpoint(item: any) {
const sourceID = item.id || item.sourceId;
if (!sourceID) return;
if (!window.confirm(`确认删除客户端接口「${sourceID}」?删除后会同步兼容 media-types.json 和 update-info.json。`)) return;
await guarded(async () => {
await api(`/api/admin/sources/${encodeURIComponent(sourceID)}`, { method: "DELETE" });
setToast("客户端接口已删除,兼容 JSON 已同步");
await Promise.all([loadSources().catch(() => undefined), loadEndpoints()]);
});
}
async function loadDatabase(options: LoadDatabaseOptions = {}) {
const data = await api<{ database: any; config?: any }>("/api/admin/database/status");
database.value = data.database;
databaseConfig.value = data.config || null;
if (!options.preserveForm || !databaseFormEditing.value) {
applyDatabaseConfig(data.config || {}, data.database || {});
databaseFormEditing.value = false;
}
if (options.previewLegacy !== false) await previewLegacySync();
}
function applyDatabaseConfig(config: any, status: any = {}) {
databaseForm.provider = config.provider || status.configProvider || "sqlite";
databaseForm.sqlitePath = config.sqlitePath || "";
databaseForm.mysqlHost = config.mysqlHost || "127.0.0.1";
databaseForm.mysqlPort = Number(config.mysqlPort || 3306);
databaseForm.mysqlDatabase = config.mysqlDatabase || "";
databaseForm.mysqlUser = config.mysqlUser || "";
databaseForm.mysqlPassword = "";
databaseForm.mysqlDsn = config.mysqlDsn || "";
databaseConfigCollapsed.value = databaseForm.provider === "mysql" && Boolean(config.mysqlHost || config.mysqlDatabase || config.mysqlDsn);
}
function databasePayload() {
return {
provider: databaseForm.provider,
sqlite_path: databaseForm.sqlitePath,
mysql_host: databaseForm.mysqlHost,
mysql_port: Number(databaseForm.mysqlPort || 3306),
mysql_database: databaseForm.mysqlDatabase,
mysql_user: databaseForm.mysqlUser,
mysql_password: databaseForm.mysqlPassword,
};
}
async function testDatabase() {
await guarded(async () => {
await api("/api/admin/database/test", {
method: "POST",
body: JSON.stringify(databasePayload()),
});
setToast("数据库连接测试通过");
});
}
async function saveDatabase() {
await guarded(async () => {
const data = await api<{ database: any; config: any }>("/api/admin/database/save", {
method: "POST",
body: JSON.stringify(databasePayload()),
});
database.value = data.database;
databaseConfig.value = data.config;
databaseFormEditing.value = false;
applyDatabaseConfig(data.config || {}, data.database || {});
databaseConfigCollapsed.value = true;
setToast("数据库配置已测试、保存并热切换");
await loadDatabase({ previewLegacy: false, preserveForm: true });
});
}
async function syncDatabase(direction: "import" | "sync") {
await guarded(async () => {
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 };
const result = databaseLastSync.value || {};
if (result.skipped) {
setToast(result.warnings?.[0] || "远端 MySQL 未配置,同步已跳过", "warn");
} else {
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
}
await loadDatabase({ previewLegacy: false });
});
}
function editDatabaseConfig() {
databaseFormEditing.value = true;
databaseConfigCollapsed.value = false;
}
function markDatabaseFormEditing() {
databaseFormEditing.value = true;
}
async function reloadDatabaseConfig() {
databaseFormEditing.value = false;
await guarded(async () => {
await loadDatabase({ previewLegacy: false });
setToast("数据库配置已从服务端重新读取");
});
}
function databaseConfigSummary() {
const config = databaseConfig.value || {};
if (databaseForm.provider === "mysql") {
const host = config.mysqlHost || databaseForm.mysqlHost || "127.0.0.1";
const port = config.mysqlPort || databaseForm.mysqlPort || 3306;
const databaseName = config.mysqlDatabase || databaseForm.mysqlDatabase || "-";
const user = config.mysqlUser || databaseForm.mysqlUser || "-";
return `${host}:${port} / ${databaseName} / ${user}${config.hasPassword ? " / 已保存密码" : ""}`;
}
return config.sqlitePath || databaseForm.sqlitePath || "使用默认 SQLite 路径";
}
async function loadMigrationStatus() {
const data = await api<{ migration: any }>("/api/admin/system/migration");
migrationStatus.value = data.migration || null;
}
async function loadBranding() {
const data = await api<{ branding: any }>("/api/admin/system/branding");
Object.assign(branding, {
siteIconUrl: data.branding?.siteIconUrl || branding.siteIconUrl,
developerAvatarUrl: data.branding?.developerAvatarUrl || branding.developerAvatarUrl,
developerName: data.branding?.developerName || "YMhut",
feedbackEmail: data.branding?.feedbackEmail || "support@ymhut.cn",
});
}
async function saveBranding() {
await guarded(async () => {
const data = await api<{ branding: any }>("/api/admin/system/branding", {
method: "POST",
body: JSON.stringify({
siteIconUrl: branding.siteIconUrl,
developerAvatarUrl: branding.developerAvatarUrl,
developerName: branding.developerName,
feedbackEmail: branding.feedbackEmail,
}),
});
Object.assign(branding, data.branding || {});
if (!mailConfig.developerAddress) mailConfig.developerAddress = branding.feedbackEmail;
setToast("站点品牌信息已保存");
});
}
async function loadMailConfig(options: LoadMailOptions = {}) {
const data = await api<{ config: any }>("/api/admin/system/mail/config");
if (!options.preserveForm || !mailConfigEditing.value) {
Object.assign(mailConfig, {
host: data.config?.host || "",
port: Number(data.config?.port || 465),
secure: data.config?.secure || "ssl",
username: data.config?.username || "",
password: "",
fromAddress: data.config?.fromAddress || "",
fromName: data.config?.fromName || "YMhut Box Feedback",
developerAddress: data.config?.developerAddress || "",
timeoutSeconds: Number(data.config?.timeoutSeconds || 20),
hasPassword: Boolean(data.config?.hasPassword),
configured: Boolean(data.config?.configured),
});
mailConfigEditing.value = false;
}
}
function mailPayload() {
return {
host: mailConfig.host,
port: Number(mailConfig.port || 465),
secure: mailConfig.secure,
username: mailConfig.username,
password: mailConfig.password,
from_address: mailConfig.fromAddress,
from_name: mailConfig.fromName,
developer_address: mailConfig.developerAddress,
timeout_seconds: Number(mailConfig.timeoutSeconds || 20),
};
}
async function saveMailConfig() {
await guarded(async () => {
const data = await api<{ config: any }>("/api/admin/system/mail/config", { method: "POST", body: JSON.stringify(mailPayload()) });
mailConfigEditing.value = false;
Object.assign(mailConfig, { ...data.config, password: "" });
setToast("邮件通知配置已保存");
});
}
async function testMail() {
await guarded(async () => {
await api("/api/admin/system/mail/test", { method: "POST", body: "{}" });
setToast("测试邮件已发送");
await loadMailConfig({ preserveForm: true });
});
}
function markMailConfigEditing() {
mailConfigEditing.value = true;
}
async function reloadMailConfig() {
mailConfigEditing.value = false;
await guarded(async () => {
await loadMailConfig();
setToast("邮件配置已从服务端重新读取");
});
}
async function retryFeedbackMail() {
if (!selectedFeedback.value) return;
await guarded(async () => {
await api(`/api/admin/feedbacks/${encodeURIComponent(selectedFeedback.value.code)}/mail/retry`, { method: "POST", body: "{}" });
setToast("反馈邮件已重新发送");
await openFeedback(selectedFeedback.value);
await loadFeedbacks();
});
}
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({ previewLegacy: false, preserveForm: true }), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
});
}
async function loadHealth() {
healthSnapshot.value = await api("/api/admin/system/health");
}
async function loadAudit() {
const params = new URLSearchParams({
page: String(auditPage.page || 1),
perPage: String(auditPage.perPage || 35),
});
if (auditPage.q) params.set("q", auditPage.q);
if (auditPage.type) params.set("type", auditPage.type);
if (auditPage.target) params.set("target", auditPage.target);
const data = await api<{ items: any[]; page?: any }>(`/api/admin/system/audit?${params}`);
const page = data.page || { items: data.items || [], total: data.items?.length || 0, page: auditPage.page, perPage: auditPage.perPage };
auditLogs.value = page.items || [];
Object.assign(auditPage, {
items: page.items || [],
total: Number(page.total || 0),
page: Number(page.page || auditPage.page || 1),
perPage: Number(page.perPage || auditPage.perPage || 35),
});
}
function setAuditPage(page: number) {
auditPage.page = Math.max(1, page);
void loadAudit();
}
function selectAuditLog(item: any) {
auditPage.selected = item;
}
async function changePassword() {
await guarded(async () => {
const data = await api<{ isDefaultPassword: boolean; warning?: string }>("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
passwordForm.currentPassword = "";
passwordForm.newPassword = "";
if (authBootstrap.value) authBootstrap.value.isDefaultPassword = data.isDefaultPassword;
setToast(data.warning || "后台密码已修改,登录页将不再提示默认密码", data.warning ? "warn" : "success");
});
}
function endpointStatus(item: any) {
return item.health?.status || item.lastStatus || "unknown";
}
function statusTone(status: string) {
const value = String(status || "").toLowerCase();
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready", "completed"].includes(value)) return "good";
if (["redirected", "degraded", "pending", "processing", "queued", "missing", "skipped", "running", "normal"].includes(value)) return "warn";
if (["error", "failed", "closed", "offline", "urgent", "high", "blocking", "major"].includes(value)) return "bad";
return "neutral";
}
function objectEntries(value: Record<string, number>) {
return Object.entries(value || {}).map(([name, item]) => ({ name: labelStatus(name), value: item || 0 }));
}
function labelStatus(value: string) {
const labels: Record<string, string> = {
ok: "正常",
redirected: "重定向健康",
error: "错误",
degraded: "降级",
unknown: "未知",
new: "新建",
processing: "处理中",
closed: "已关闭",
pending: "待发送",
sent: "已发送",
skipped: "已跳过",
running: "执行中",
completed: "已完成",
failed: "失败",
};
return labels[value] || value || "未知";
}
function labelPriority(value: string) {
const labels: Record<string, string> = {
low: "低",
minor: "低",
normal: "普通",
medium: "普通",
high: "高",
major: "高",
urgent: "紧急",
blocking: "紧急",
};
return labels[String(value || "").toLowerCase()] || value || "普通";
}
function auditTypeLabel(value: string) {
const labels: Record<string, string> = {
"auth.login": "管理员登录",
"auth.password_changed": "修改后台密码",
"feedback.created": "客户端提交反馈",
"feedback.updated": "更新反馈工单",
"legacy_json.saved": "保存兼容 JSON",
"legacy_json.restored": "恢复兼容 JSON",
"legacy_json.seeded": "导入 JSON 基板",
"release_notice.saved": "保存版本日志",
"release.package_uploaded": "上传发布包",
"legacy.sync": "旧项目同步",
};
return labels[value] || value || "未知操作";
}
function auditMessage(item: any) {
const message = String(item?.message || "");
const legacy: Record<string, string> = {
"Admin login": "管理员登录",
"Admin password changed": "后台密码已修改",
"Legacy JSON saved": "兼容 JSON 已保存",
"Legacy JSON restored": "兼容 JSON 已恢复",
"Release notice saved": "版本日志已保存",
"Feedback updated": "反馈工单已更新",
};
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 databaseSyncStatusLabel(value: string) {
const labels: Record<string, string> = {
completed: "已完成",
skipped: "已跳过",
running: "执行中",
failed: "失败",
};
return labels[String(value || "").toLowerCase()] || 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"];
let next = value;
let index = 0;
while (next >= 1024 && index < units.length - 1) {
next /= 1024;
index += 1;
}
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function timeLabel(value: string) {
if (!value) return "-";
return value.length > 10 ? value.slice(11, 19) : value;
}
function heartbeatTimeValue(value: string) {
if (!value) return Date.now();
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : value;
}
function pretty(value: any) {
return JSON.stringify(value || {}, null, 2);
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null));
}
function parseJSONSafe(value: string, fallback: any) {
try {
return JSON.parse(value || "{}");
} catch {
return clone(fallback || {});
}
}
function findByID(list: any, id: string) {
if (!Array.isArray(list)) return null;
return list.find((item) => item?.id === id) || null;
}
function splitList(value: string) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
onMounted(() => {
localStorage.removeItem("ymhut.csrf");
void load();
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();
events = null;
});
function connectAdminEvents() {
if (!csrf.value || events) return;
events = new EventSource("/api/admin/events", { withCredentials: true });
const refreshCurrent = () => {
if (autoRefreshPaused.value) return;
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/sources") void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
if (currentPath.value === "/admin/system") void loadSystem({ preserveForms: true });
};
for (const name of ["source_check.item", "source_check.progress", "source_check.completed", "heartbeat"]) {
events.addEventListener(name, refreshCurrent);
}
events.onerror = () => {
events?.close();
events = null;
window.setTimeout(connectAdminEvents, 5000);
};
}
</script>
<template>
<Teleport to="body">
<div v-if="toast" :class="['toast', toast.type]">{{ toast.message }}</div>
</Teleport>
<main v-if="currentPath === '/admin/login'" class="login-shell">
<section class="login-panel">
<div>
<p class="eyebrow">YMhut Unified Management</p>
<h1>后台登录</h1>
<p class="muted">验证码和密码都由服务端校验登录后写操作继续要求 CSRF Token</p>
</div>
<p v-if="authBootstrap?.isDefaultPassword" class="alert-line">
当前使用默认账号{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
</p>
<form class="form-stack" @submit.prevent="login">
<label>账号<input v-model="loginForm.username" autocomplete="username" /></label>
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" /></label>
<label>
验证码
<div class="captcha-row">
<input v-model="loginForm.captcha" />
<button class="captcha-button" type="button" title="刷新验证码" @click="loadCaptcha">
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</label>
<button class="btn primary full" type="submit">登录</button>
</form>
</section>
</main>
<main v-else class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">
<img v-if="branding.siteIconUrl" :src="branding.siteIconUrl" alt="YMhut" />
<ShieldCheck v-else :size="22" />
</span>
<div><strong>{{ branding.developerName || "YMhut" }}</strong><small>统一管理台</small></div>
</div>
<nav class="nav-groups">
<section v-for="group in navGroups" :key="group.label" class="nav-group">
<p>{{ group.label }}</p>
<button
v-for="item in group.items"
:key="item.path"
:class="{ active: currentPath === item.path }"
@click="navigate(item.path)"
>
<component :is="item.icon" :size="17" />
<span>{{ item.label }}</span>
</button>
</section>
</nav>
<button class="logout" @click="logout"><LogOut :size="16" />退出</button>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">update.ymhut.cn</p>
<h1>{{ pageMeta.label }}</h1>
<p class="muted">{{ pageMeta.description }}</p>
</div>
<div class="top-actions">
<span v-if="loading" class="badge warn">加载中</span>
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
</div>
</header>
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />
<LegacyJsonView v-else-if="activeLegacyName" :ctx="viewContext" />
<SourcesView v-else-if="currentPath === '/admin/sources'" :ctx="viewContext" />
<EndpointsView v-else-if="currentPath === '/admin/endpoints'" :ctx="viewContext" />
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
</section>
</main>
</template>