Files
YMhut-box-C-/server/unified-management/web/admin/src/App.vue
T

1809 lines
69 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 Button from "primevue/button";
import ConfirmDialog from "primevue/confirmdialog";
import Tag from "primevue/tag";
import Toast from "primevue/toast";
import Toolbar from "primevue/toolbar";
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";
import { applyDocumentBranding, normalizeBranding } from "./branding";
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
const EndpointsView = defineAsyncComponent(() => import("./views/EndpointsView.vue"));
const FeedbacksView = defineAsyncComponent(() => import("./views/FeedbacksView.vue"));
const LegacyJsonView = defineAsyncComponent(() => import("./views/LegacyJsonView.vue"));
const ReleasesView = defineAsyncComponent(() => import("./views/ReleasesView.vue"));
const SourcesView = defineAsyncComponent(() => import("./views/SourcesView.vue"));
const SystemView = defineAsyncComponent(() => import("./views/SystemView.vue"));
const AuditLogView = defineAsyncComponent(() => import("./views/AuditLogView.vue"));
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "logs" | "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 loginPending = 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 systemRefreshTimer: 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, selectedCodes: feedbackSelectedCodes, detailTab: feedbackDetailTab, tagInput: feedbackTagInput } = 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, databaseSyncJob, databaseSyncOutput, healthSnapshot, auditLogs, auditPage, systemLogPage, 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/audit", label: "审计日志", description: "操作审计、登录记录与安全事件", icon: ShieldCheck },
{ 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/audit", "/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 sourceRows = computed(() => sourceCategories.value.flatMap((cat: any) => (cat.subcategories || []).map((src: any) => ({
...src,
categoryName: cat.name || cat.id || src.categoryName || src.categoryId || "未分类",
status: endpointStatus(src),
latencyMs: sourceLatency(src),
checkedAt: sourceCheckedAt(src),
healthError: sourceHealthError(src),
}))));
const sourceAvailability = computed(() => {
const total = sourceRows.value.length;
const healthy = sourceRows.value.filter((item: any) => ["ok", "redirected"].includes(item.status)).length;
return total ? Math.round((healthy / total) * 100) : 0;
});
const sourceAverageLatency = computed(() => averageLatency(sourceRows.value.map((item: any) => item.latencyMs)));
const sourceMaxLatency = computed(() => {
const values = sourceRows.value.map((item: any) => Number(item.latencyMs)).filter((item: number) => Number.isFinite(item) && item >= 0);
return values.length ? Math.max(...values) : 0;
});
const sourceLastCheckedAt = computed(() => sourceRows.value.map((item: any) => item.checkedAt).filter(Boolean).sort().pop() || "");
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;
});
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
const averageLatencyRows = computed(() => {
const rows = dashboard.value?.averageLatency || dashboard.value?.average_latency || [];
if (Array.isArray(rows) && rows.length) {
return rows.map((item: any) => ({
label: item.label || timeLabel(item.checkedAt || item.checked_at),
latency: Number(item.averageLatency ?? item.avgLatencyMs ?? item.average_latency ?? item.latencyMs ?? 0),
sampleCount: Number(item.sampleCount ?? item.sample_count ?? 0),
checkedAt: item.checkedAt || item.checked_at || "",
})).filter((item: any) => Number.isFinite(item.latency));
}
return heartbeatChartRows.value.map((item: any) => ({ ...item, sampleCount: 1 }));
});
const isAverageLatencyChartEmpty = computed(() => averageLatencyRows.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: averageLatencyRows.value.length <= 1,
data: averageLatencyRows.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: averageLatencyRows.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,
databaseSyncJob: databaseSyncJob.value,
databaseSyncOutput: databaseSyncOutput.value,
databaseSyncStatusLabel,
databaseSyncDirectionLabel,
databaseSyncTableCount,
databaseConfigSummary,
deleteEndpoint,
editDatabaseConfig,
endpointStatus,
endpoints: endpoints.value,
feedbackFilters,
feedbackOption: feedbackOption.value,
feedbackPage: feedbackPage.value,
feedbackUpdate,
feedbackSelectedCodes: feedbackSelectedCodes.value,
feedbackDetailTab: feedbackDetailTab.value,
feedbackTagInput: feedbackTagInput.value,
setFeedbackDetailTab: (tab: string) => { feedbackDetailTab.value = tab as any; },
setFeedbackTagInput: (val: string) => { feedbackTagInput.value = val; },
addFeedbackTag,
removeFeedbackTag,
toggleFeedbackCode,
toggleAllFeedbackCodes,
bulkUpdateFeedbacks,
formatBytes,
formatDateTime,
formatHealthOutput,
healthOption: healthOption.value,
healthSnapshot: healthSnapshot.value,
healthyEndpointCount: healthyEndpointCount.value,
heartbeatOption: heartbeatOption.value,
heartbeats: heartbeats.value,
isHeartbeatChartEmpty: isHeartbeatChartEmpty.value,
isAverageLatencyChartEmpty: isAverageLatencyChartEmpty.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,
loadSystemLogs,
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,
sourceRows: sourceRows.value,
sourceAvailability: sourceAvailability.value,
sourceAverageLatency: sourceAverageLatency.value,
sourceMaxLatency: sourceMaxLatency.value,
sourceLastCheckedAt: sourceLastCheckedAt.value,
sourceDraft,
sourceLatency,
sourceCheckedAt,
sourceHealthError,
statusTone,
syncDatabase,
systemLogPage,
systemTab: systemTab.value,
setSystemTab,
setAuditPage,
setSystemLogPage,
selectSystemLog,
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 === "logs" || 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 adminFetch<Captcha>("/api/admin/auth/captcha", {}, { timeoutMs: 5000 });
}
async function loadAuthBootstrap() {
authBootstrap.value = await api<AuthBootstrap>("/api/admin/auth/bootstrap");
}
async function login() {
if (loginPending.value) return;
if (!loginForm.password || !loginForm.captcha || !captcha.value?.captchaId) {
setToast("请填写密码和验证码", "warn");
return;
}
loginPending.value = true;
loading.value = true;
try {
const data = await adminFetch<{ csrfToken: string }>("/api/admin/auth/login", {
method: "POST",
body: JSON.stringify({ ...loginForm, captchaId: captcha.value?.captchaId }),
}, { timeoutMs: 8000 });
csrf.value = data.csrfToken;
sessionStorage.setItem("ymhut.csrf", csrf.value);
localStorage.removeItem("ymhut.csrf");
connectAdminEvents();
navigate("/admin/dashboard");
} catch (error) {
const message = toChineseError(error instanceof Error ? error.message : String(error));
setToast(message, "error");
loginForm.captcha = "";
void loadCaptcha().catch(() => {
captcha.value = null;
});
} finally {
loading.value = false;
loginPending.value = false;
}
}
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 Promise.all([
loadDashboard(),
loadSources().catch(() => undefined),
loadEndpoints().catch(() => undefined),
loadSourceCheckJobs().catch(() => undefined),
]);
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/audit") await loadAudit();
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(),
loadSystemLogs(),
loadDatabaseSyncLatest(),
loadMigrationStatus(),
loadBranding(),
]);
}
async function loadFeedbacks(page?: number) {
if (page !== undefined) feedbackFilters.page = page;
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);
if ((feedbackFilters as any).category) params.set("category", (feedbackFilters as any).category);
if ((feedbackFilters as any).assignee) params.set("assignee", (feedbackFilters as any).assignee);
const data = await api<{ page: any }>(`/api/admin/feedbacks?${params}`);
feedbackPage.value = data.page || { items: [], total: 0, page: 1, perPage: 20 };
feedbackSelectedCodes.value = [];
}
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 || "";
feedbackUpdate.assignee = data.feedback.assignee || "";
feedbackUpdate.tags = data.feedback.tags ? [...data.feedback.tags] : [];
feedbackDetailTab.value = "info";
}
function toggleFeedbackCode(code: string) {
const idx = feedbackSelectedCodes.value.indexOf(code);
if (idx >= 0) feedbackSelectedCodes.value.splice(idx, 1);
else feedbackSelectedCodes.value.push(code);
}
function toggleAllFeedbackCodes() {
const items: any[] = feedbackPage.value?.items || [];
if (feedbackSelectedCodes.value.length === items.length) {
feedbackSelectedCodes.value = [];
} else {
feedbackSelectedCodes.value = items.map((item: any) => item.code);
}
}
async function bulkUpdateFeedbacks(payload: { status?: string; assignee?: string; tags?: string[] }) {
if (!feedbackSelectedCodes.value.length) return;
await guarded(async () => {
await api("/api/admin/feedbacks/bulk", {
method: "PATCH",
body: JSON.stringify({ codes: feedbackSelectedCodes.value, ...payload }),
});
setToast(`已批量更新 ${feedbackSelectedCodes.value.length} 条工单`);
feedbackSelectedCodes.value = [];
await loadFeedbacks();
});
}
function addFeedbackTag() {
const tag = feedbackTagInput.value.trim();
if (!tag || feedbackUpdate.tags.includes(tag)) { feedbackTagInput.value = ""; return; }
feedbackUpdate.tags = [...feedbackUpdate.tags, tag];
feedbackTagInput.value = "";
}
function removeFeedbackTag(tag: string) {
feedbackUpdate.tags = feedbackUpdate.tags.filter((t) => t !== tag);
}
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;
}
let completed = false;
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 = "";
completed = true;
setToast("发布包已上传并放入下载目录");
await loadReleases();
window.setTimeout(() => {
if (!uploadDraft.uploading) {
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = 0;
uploadDraft.status = "";
}
}, 1200);
}).finally(() => {
uploadDraft.uploading = false;
if (!completed) {
uploadDraft.progress = 0;
uploadDraft.loadedBytes = 0;
uploadDraft.totalBytes = uploadDraft.file?.size || 0;
uploadDraft.status = "上传失败,可直接重试";
}
});
}
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 (data.database?.currentSyncJob) {
databaseSyncJob.value = data.database.currentSyncJob;
databaseSyncOutput.value = data.database.currentSyncJob.output || [];
}
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 payload = { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite" };
const data = await api<{ jobId: number; job: any }>("/api/admin/database/sync/jobs", { method: "POST", body: JSON.stringify(payload) });
databaseSyncJob.value = data.job;
databaseLastSync.value = data.job;
databaseSyncOutput.value = data.job?.output || [];
setToast("数据库同步任务已启动");
await pollDatabaseSyncJob(data.jobId || data.job?.id);
await Promise.all([loadDatabase({ previewLegacy: false, preserveForm: true }), loadSystemLogs()]);
});
}
async function loadDatabaseSyncLatest() {
const data = await api<{ job: any | null }>("/api/admin/database/sync/jobs/latest");
if (data.job) {
databaseSyncJob.value = data.job;
databaseLastSync.value = data.job;
databaseSyncOutput.value = data.job.output || [];
}
}
async function pollDatabaseSyncJob(id: number) {
if (!id) return;
for (let index = 0; index < 120; index += 1) {
const data = await api<{ job: any }>(`/api/admin/database/sync/jobs/${id}`);
databaseSyncJob.value = data.job;
databaseLastSync.value = data.job;
databaseSyncOutput.value = data.job?.output || [];
if (!data.job || data.job.status !== "running") {
if (data.job?.status === "failed") setToast(data.job.errors?.[0] || "数据库同步失败", "error");
else if (data.job?.status === "skipped") setToast(data.job.warnings?.[0] || "数据库同步已跳过", "warn");
else setToast("数据库同步已完成");
return;
}
await delay(1000);
}
setToast("数据库同步仍在执行,输出会继续随状态刷新", "warn");
}
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, normalizeBranding(data.branding || branding));
applyDocumentBranding(branding, "admin");
}
async function saveBranding() {
await guarded(async () => {
const data = await api<{ branding: any }>("/api/admin/system/branding", {
method: "POST",
body: JSON.stringify({
siteName: branding.siteName,
portalTitle: branding.portalTitle,
portalSubtitle: branding.portalSubtitle,
adminTitle: branding.adminTitle,
adminSubtitle: branding.adminSubtitle,
siteIconUrl: branding.siteIconUrl,
logoUrl: branding.logoUrl,
developerAvatarUrl: branding.developerAvatarUrl,
developerName: branding.developerName,
feedbackEmail: branding.feedbackEmail,
}),
});
Object.assign(branding, normalizeBranding(data.branding || branding));
applyDocumentBranding(branding, "admin");
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 loadSystemLogs() {
const params = new URLSearchParams({
page: String(systemLogPage.page || 1),
perPage: String(systemLogPage.perPage || 35),
});
if (systemLogPage.q) params.set("q", systemLogPage.q);
if (systemLogPage.category) params.set("category", systemLogPage.category);
const data = await api<{ items: any[]; page?: any }>(`/api/admin/system/logs?${params}`);
const page = data.page || { items: data.items || [], total: data.items?.length || 0, page: systemLogPage.page, perPage: systemLogPage.perPage };
Object.assign(systemLogPage, {
items: page.items || [],
total: Number(page.total || 0),
page: Number(page.page || systemLogPage.page || 1),
perPage: Number(page.perPage || systemLogPage.perPage || 35),
});
}
function setSystemLogPage(page: number) {
systemLogPage.page = Math.max(1, page);
void loadSystemLogs();
}
function selectSystemLog(item: any) {
systemLogPage.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 sourceLatency(item: any) {
return firstFiniteNumber(item.health?.latencyMs, item.health?.latency_ms, item.lastLatencyMs, item.last_latency_ms, item.latencyMs, item.latency_ms) ?? 0;
}
function sourceCheckedAt(item: any) {
return item.health?.lastCheckedAt || item.health?.last_checked_at || item.lastCheckedAt || item.last_checked_at || item.checkedAt || item.checked_at || "";
}
function sourceHealthError(item: any) {
return item.health?.lastError || item.health?.last_error || item.lastError || item.last_error || item.error || "";
}
function averageLatency(values: unknown[]) {
const numeric = values.map((item) => Number(item)).filter((item) => Number.isFinite(item) && item >= 0);
return numeric.length ? Math.round(numeric.reduce((sum, item) => sum + item, 0) / numeric.length) : 0;
}
function firstFiniteNumber(...values: unknown[]) {
for (const value of values) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 0) return numeric;
}
return null;
}
function formatDateTime(value: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
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 formatHealthOutput(item: any) {
const raw = String(item?.error || item?.lastError || "").trim();
if (!raw) return "-";
try {
const meta = JSON.parse(raw);
const parts = [
meta.finalStatus ? `状态 ${meta.finalStatus}` : "",
meta.finalUrl ? `最终 URL ${meta.finalUrl}` : "",
meta.resolvedUrl ? `媒体 ${meta.resolvedUrl}` : "",
meta.mediaType ? `类型 ${meta.mediaType}` : "",
meta.error ? `错误 ${meta.error}` : "",
].filter(Boolean);
return parts.length ? parts.join(" / ") : raw;
} catch {
return raw;
}
}
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);
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
onMounted(() => {
localStorage.removeItem("ymhut.csrf");
void load();
refreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]);
if (!autoRefreshPaused.value && currentPath.value === "/admin/sources" && csrf.value) void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]);
if (!autoRefreshPaused.value && currentPath.value === "/admin/endpoints" && csrf.value) void loadEndpoints();
}, 20000);
systemRefreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/system" && csrf.value) void loadSystem({ preserveForms: true });
}, 60000);
});
watch(currentPath, () => {
void load();
});
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer);
if (systemRefreshTimer) window.clearInterval(systemRefreshTimer);
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.addEventListener("source.changed", () => {
if (autoRefreshPaused.value) return;
if (currentPath.value === "/admin/dashboard") void Promise.all([loadDashboard(), loadSources().catch(() => undefined)]);
if (currentPath.value === "/admin/sources") void loadSources();
if (currentPath.value === "/admin/endpoints") void loadEndpoints();
});
events.addEventListener("release.changed", () => {
if (autoRefreshPaused.value) return;
if (currentPath.value === "/admin/dashboard") void loadDashboard();
if (currentPath.value === "/admin/releases") void loadReleases();
if (activeLegacyName.value) void loadLegacy(activeLegacyName.value);
});
events.addEventListener("branding.changed", () => {
if (autoRefreshPaused.value) return;
void loadBranding();
});
events.addEventListener("feedback.changed", () => {
if (autoRefreshPaused.value) return;
if (currentPath.value === "/admin/dashboard") void loadDashboard();
if (currentPath.value === "/admin/feedbacks") void loadFeedbacks();
});
events.onerror = () => {
events?.close();
events = null;
window.setTimeout(connectAdminEvents, 5000);
};
}
</script>
<template>
<Toast />
<ConfirmDialog />
<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">{{ branding.siteName }}</p>
<h1>后台登录</h1>
<p class="muted">{{ branding.adminSubtitle }}验证码和密码都由服务端校验登录后写操作继续要求 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" :disabled="loginPending" /></label>
<label>密码<input v-model="loginForm.password" type="password" autocomplete="current-password" :disabled="loginPending" /></label>
<label>
验证码
<div class="captcha-row">
<input v-model="loginForm.captcha" :disabled="loginPending" autocomplete="off" />
<button class="captcha-button" type="button" title="刷新验证码" :disabled="loginPending" @click="loadCaptcha">
<img v-if="captcha?.image" :src="captcha.image" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</label>
<Button class="full" type="submit" :label="loginPending ? '正在登录…' : '登录'" :loading="loginPending" :disabled="loginPending" />
</form>
</section>
</main>
<main v-else class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark">
<img v-if="branding.logoUrl || branding.siteIconUrl" :src="branding.logoUrl || branding.siteIconUrl" :alt="branding.siteName" />
<ShieldCheck v-else :size="22" />
</span>
<div><strong>{{ branding.adminTitle || "统一管理台" }}</strong><small>{{ branding.siteName }}</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">
<Toolbar class="topbar">
<template #start>
<div>
<p class="eyebrow">{{ branding.adminSubtitle }}</p>
<h1>{{ pageMeta.label }}</h1>
<p class="muted">{{ pageMeta.description }}</p>
</div>
</template>
<template #end>
<div class="top-actions">
<Tag v-if="loading" severity="warn" value="加载中" />
<Button severity="secondary" outlined rounded @click="load"><RefreshCw :size="16" />刷新</Button>
</div>
</template>
</Toolbar>
<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" />
<AuditLogView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
<SystemView v-else-if="currentPath === '/admin/system'" :ctx="viewContext" />
</section>
</main>
</template>