Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
@@ -0,0 +1,777 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDownToLine,
CheckCircle2,
ClipboardList,
Code2,
Database,
FileJson,
HeartPulse,
LayoutDashboard,
ListChecks,
LogOut,
MessageSquareText,
Network,
RefreshCw,
Settings,
ShieldCheck,
} from "lucide-vue-next";
import AuditView from "./views/AuditView.vue";
import DashboardView from "./views/DashboardView.vue";
import DatabaseView from "./views/DatabaseView.vue";
import EndpointsView from "./views/EndpointsView.vue";
import FeedbacksView from "./views/FeedbacksView.vue";
import HealthView from "./views/HealthView.vue";
import LegacyJsonView from "./views/LegacyJsonView.vue";
import ReleasesView from "./views/ReleasesView.vue";
import SettingsView from "./views/SettingsView.vue";
import SourcesView from "./views/SourcesView.vue";
type LegacyName = "update-info" | "media-types";
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 csrf = ref(localStorage.getItem("ymhut.csrf") || "");
const route = useRoute();
const router = useRouter();
const currentPath = computed(() => normalizeAdminPath(route.path));
const loading = ref(false);
const toast = ref("");
const autoRefreshPaused = ref(false);
let refreshTimer: number | undefined;
const captcha = ref<Captcha | null>(null);
const authBootstrap = ref<AuthBootstrap | null>(null);
const dashboard = ref<any>({});
const feedbackPage = ref<any>({ items: [], total: 0, page: 1, perPage: 20 });
const selectedFeedback = ref<any | null>(null);
const releases = ref<any>(null);
const releaseNotices = ref<any[]>([]);
const selectedNotice = ref<any | null>(null);
const sources = ref<any>({ categories: [] });
const endpoints = ref<any[]>([]);
const database = ref<any>(null);
const healthSnapshot = ref<any>(null);
const auditLogs = ref<any[]>([]);
const legacySync = ref<any>(null);
const legacyDocuments = reactive<Record<LegacyName, any | null>>({ "update-info": null, "media-types": null });
const loginForm = reactive({ username: "admin", password: "", captcha: "" });
const passwordForm = reactive({ currentPassword: "", newPassword: "" });
const feedbackFilters = reactive({ q: "", status: "", page: 1, perPage: 20 });
const feedbackUpdate = reactive({ status: "", statusDetail: "", publicReply: "" });
const commentDraft = reactive({ body: "", internal: true });
const databaseForm = reactive({ provider: "sqlite", sqlitePath: "", mysqlDsn: "" });
const sourceDraft = reactive({
sourceId: "",
categoryId: "custom",
categoryName: "自定义接口",
name: "",
description: "",
method: "GET",
apiUrl: "",
urlTemplate: "",
thumbnailUrl: "",
proxyMode: "client_direct",
timeoutMs: 8000,
retryCount: 1,
cacheSeconds: 300,
checkIntervalSec: 300,
enabled: true,
clientVisible: true,
supportedFormats: "[\"json\"]",
});
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null }>>({
"update-info": { raw: "", note: "", preview: null },
"media-types": { raw: "", note: "", preview: null },
});
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
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/database", label: "数据库与同步", description: "SQLite、MySQL 和旧项目同步", icon: Database },
{ path: "/admin/health", label: "健康快照", description: "服务端运行状态和预检信息", icon: HeartPulse },
{ path: "/admin/settings", label: "系统设置", description: "密码与旧库同步入口", icon: Settings },
{ path: "/admin/audit", label: "审计日志", description: "后台操作和同步记录", icon: ListChecks },
];
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/database", "/admin/health", "/admin/settings", "/admin/audit"].includes(item.path)) },
];
const quickActions = [
{ path: "/admin/feedbacks", label: "反馈处理", description: "查看和处理客户端反馈工单", icon: MessageSquareText },
{ path: "/admin/releases", label: "发布与日志", description: "维护发布包和 update-notice", icon: ArrowDownToLine },
{ path: "/admin/legacy/update-info", label: "更新 JSON", description: "编辑旧版 update-info.json", icon: FileJson },
{ path: "/admin/legacy/media-types", label: "媒体源 JSON", description: "同步旧客户端媒体源结构", icon: ClipboardList },
{ path: "/admin/sources", label: "接口源目录", description: "新增接口并执行健康检测", icon: Network },
{ path: "/admin/database", label: "数据库同步", description: "管理 SQLite/MySQL 和旧项目同步", icon: Database },
{ path: "/admin/audit", label: "审计日志", description: "查看后台操作与同步记录", icon: ListChecks },
];
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) => endpointStatus(item) === "ok").length);
const latestNotice = computed(() => releaseNotices.value[0] || null);
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
const heartbeatOption = computed(() => ({
tooltip: { trigger: "axis" },
grid: { left: 44, right: 18, top: 28, bottom: 34 },
xAxis: {
type: "category",
data: heartbeats.value.slice().reverse().map((item: any) => timeLabel(item.checkedAt)),
axisLine: { lineStyle: { color: "#cbd5e1" } },
},
yAxis: { type: "value", name: "ms", axisLine: { lineStyle: { color: "#cbd5e1" } }, splitLine: { lineStyle: { color: "#e5e7eb" } } },
series: [
{
name: "接口延迟",
type: "line",
smooth: true,
areaStyle: { opacity: 0.18 },
data: heartbeats.value.slice().reverse().map((item: any) => item.latencyMs || 0),
color: "#2563eb",
},
],
}));
const healthOption = computed(() => ({
tooltip: { trigger: "item" },
legend: { bottom: 0 },
series: [
{
name: "接口健康",
type: "pie",
radius: ["48%", "72%"],
data: objectEntries(sourceHealth.value),
color: ["#16a34a", "#f59e0b", "#dc2626", "#64748b"],
},
],
}));
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);
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 viewContext = computed(() => ({
activeLegacyLabel: activeLegacyLabel.value,
activeLegacyName: activeLegacyName.value,
addFeedbackComment,
auditLogs: auditLogs.value,
autoRefreshPaused: autoRefreshPaused.value,
availabilityOption: availabilityOption.value,
changePassword,
checkSources,
clientCalls: clientCalls.value,
commentDraft,
copyEndpointToSource,
database: database.value,
databaseForm,
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,
importNotices,
kpis: kpis.value,
labelStatus,
latestNotice: latestNotice.value,
legacyDocuments,
legacyDrafts,
legacySync: legacySync.value,
loadAudit,
loadFeedbacks,
navigate,
noticeDraft,
openFeedback,
openNotice,
passwordForm,
pretty,
previewLegacySync,
quickActions,
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,
sourceDraft,
statusTone,
syncDatabase,
testDatabase,
toggleAutoRefresh,
validateLegacy,
validateNotice,
visibleEndpointCount: visibleEndpointCount.value,
}));
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json");
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
const res = await fetch(target, { ...init, headers, credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${res.status}`);
return data as T;
}
function normalizeAdminPath(value: string) {
if (value === "/admin" || value === "/admin/") return "/admin/dashboard";
if (value === "/") return "/admin/dashboard";
return value;
}
function navigate(next: string) {
if (currentPath.value === next) {
void load();
return;
}
void router.push(next);
}
function toggleAutoRefresh() {
autoRefreshPaused.value = !autoRefreshPaused.value;
}
function setToast(message: string) {
toast.value = message;
window.setTimeout(() => {
if (toast.value === message) toast.value = "";
}, 4200);
}
async function guarded(task: () => Promise<void>) {
loading.value = true;
try {
await task();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.value = message;
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
navigate("/admin/login");
}
} finally {
loading.value = false;
}
}
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;
localStorage.setItem("ymhut.csrf", csrf.value);
navigate("/admin/dashboard");
});
}
async function logout() {
await api("/api/admin/auth/logout", { method: "POST", body: "{}" }).catch(() => undefined);
csrf.value = "";
localStorage.removeItem("ymhut.csrf");
navigate("/admin/login");
}
async function load() {
await guarded(async () => {
if (currentPath.value === "/admin/login") {
await Promise.all([loadAuthBootstrap(), loadCaptcha()]);
return;
}
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/feedbacks") await loadFeedbacks();
if (currentPath.value === "/admin/releases") await loadReleases();
if (currentPath.value === "/admin/sources") await loadSources();
if (currentPath.value === "/admin/endpoints") await loadEndpoints();
if (currentPath.value === "/admin/database") await loadDatabase();
if (currentPath.value === "/admin/health") await loadHealth();
if (currentPath.value === "/admin/audit") await loadAudit();
if (currentPath.value === "/admin/settings") await previewLegacySync();
const legacyName = activeLegacyName.value;
if (legacyName) await loadLegacy(legacyName);
});
}
async function loadDashboard() {
dashboard.value = await api("/api/admin/dashboard/overview?window=24h");
}
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);
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.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() {
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 || [];
if (releaseNotices.value.length && !noticeDraft.version) await openNotice(releaseNotices.value[0].version);
}
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();
});
}
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;
}
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 () => {
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].note = "";
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;
setToast("兼容 JSON 已恢复");
});
}
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 () => {
await api("/api/admin/sources/check", { method: "POST", body: "{}" });
setToast("接口心跳检测已进入队列");
if (currentPath.value === "/admin/dashboard") await loadDashboard();
if (currentPath.value === "/admin/sources") await loadSources();
});
}
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 loadDatabase() {
const data = await api<{ database: any }>("/api/admin/database/status");
database.value = data.database;
databaseForm.provider = data.database?.configProvider || "sqlite";
await previewLegacySync();
}
async function testDatabase() {
await guarded(async () => {
await api("/api/admin/database/test", {
method: "POST",
body: JSON.stringify({ provider: databaseForm.provider, sqlite_path: databaseForm.sqlitePath, mysql_dsn: databaseForm.mysqlDsn }),
});
setToast("数据库连接测试通过");
});
}
async function syncDatabase(direction: "import" | "sync") {
await guarded(async () => {
await api(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
await loadDatabase();
});
}
async function previewLegacySync() {
legacySync.value = await api("/api/admin/sync/legacy/preview").catch((error) => ({ ok: false, errors: [String(error)] }));
}
async function runLegacySync() {
await guarded(async () => {
legacySync.value = await api("/api/admin/sync/legacy/run", { method: "POST", body: "{}" });
setToast("旧项目同步已完成");
await Promise.all([loadDatabase(), loadReleases().catch(() => undefined), loadSources().catch(() => undefined)]);
});
}
async function loadHealth() {
healthSnapshot.value = await api("/api/admin/system/health");
}
async function loadAudit() {
const data = await api<{ items: any[] }>("/api/admin/system/audit");
auditLogs.value = data.items || [];
}
async function changePassword() {
await guarded(async () => {
await api("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
passwordForm.currentPassword = "";
passwordForm.newPassword = "";
setToast("后台密码已修改,登录页将不再提示默认密码");
});
}
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"].includes(value)) return "good";
if (["degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
if (["error", "failed", "closed", "offline"].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: "正常",
error: "错误",
degraded: "降级",
unknown: "未知",
new: "新建",
processing: "处理中",
closed: "已关闭",
failed: "失败",
};
return labels[value] || value || "未知";
}
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 pretty(value: any) {
return JSON.stringify(value || {}, null, 2);
}
onMounted(() => {
void load();
refreshTimer = window.setInterval(() => {
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
}, 15000);
});
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer);
});
</script>
<template>
<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>
<p v-if="toast" class="notice">{{ toast }}</p>
</section>
</main>
<main v-else class="app-shell">
<aside class="sidebar">
<div class="brand">
<span class="brand-mark"><ShieldCheck :size="22" /></span>
<div><strong>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 || (item.path.includes('/legacy/') && activeLegacyName) }"
@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>
<p v-if="toast" class="notice">{{ toast }}</p>
<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" />
<DatabaseView v-else-if="currentPath === '/admin/database'" :ctx="viewContext" />
<HealthView v-else-if="currentPath === '/admin/health'" :ctx="viewContext" />
<SettingsView v-else-if="currentPath === '/admin/settings'" :ctx="viewContext" />
<AuditView v-else-if="currentPath === '/admin/audit'" :ctx="viewContext" />
</section>
</main>
</template>