1512 lines
56 KiB
Vue
1512 lines
56 KiB
Vue
<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>
|