更新了update门户站点界面和部分功能
This commit is contained in:
@@ -30,6 +30,7 @@ import SettingsView from "./views/SettingsView.vue";
|
||||
import SourcesView from "./views/SourcesView.vue";
|
||||
|
||||
type LegacyName = "update-info" | "media-types";
|
||||
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||
|
||||
type Captcha = {
|
||||
captchaId: string;
|
||||
@@ -54,9 +55,10 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||
const loading = ref(false);
|
||||
const toast = ref("");
|
||||
const toast = ref<ToastState | null>(null);
|
||||
const autoRefreshPaused = ref(false);
|
||||
let refreshTimer: number | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
|
||||
const captcha = ref<Captcha | null>(null);
|
||||
const authBootstrap = ref<AuthBootstrap | null>(null);
|
||||
@@ -99,11 +101,20 @@ const sourceDraft = reactive({
|
||||
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 legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
|
||||
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
||||
});
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
const uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
channel: "stable",
|
||||
notes: "",
|
||||
updateManifest: true,
|
||||
});
|
||||
|
||||
const routes: RouteItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||
@@ -151,7 +162,7 @@ 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 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");
|
||||
|
||||
@@ -176,19 +187,25 @@ const heartbeatOption = computed(() => ({
|
||||
],
|
||||
}));
|
||||
|
||||
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 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" },
|
||||
@@ -200,7 +217,7 @@ const feedbackOption = computed(() => ({
|
||||
|
||||
const availabilityOption = computed(() => {
|
||||
const total = Number(kpis.value.sourceTotal || 0);
|
||||
const ok = Number(sourceHealth.value.ok || 0);
|
||||
const ok = Number(sourceHealth.value.ok || 0) + Number(sourceHealth.value.redirected || 0);
|
||||
const value = total ? Math.round((ok / total) * 100) : 0;
|
||||
return {
|
||||
series: [
|
||||
@@ -217,10 +234,21 @@ const availabilityOption = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
auditLogs: auditLogs.value,
|
||||
autoRefreshPaused: autoRefreshPaused.value,
|
||||
availabilityOption: availabilityOption.value,
|
||||
@@ -254,11 +282,13 @@ const viewContext = computed(() => ({
|
||||
loadFeedbacks,
|
||||
navigate,
|
||||
noticeDraft,
|
||||
onPackageSelected,
|
||||
openFeedback,
|
||||
openNotice,
|
||||
passwordForm,
|
||||
pretty,
|
||||
previewLegacySync,
|
||||
removeItem,
|
||||
quickActions,
|
||||
releaseNotices: releaseNotices.value,
|
||||
releasePackages: releasePackages.value,
|
||||
@@ -278,6 +308,11 @@ const viewContext = computed(() => ({
|
||||
syncDatabase,
|
||||
testDatabase,
|
||||
toggleAutoRefresh,
|
||||
updateLegacyRawFromForm,
|
||||
uploadDraft,
|
||||
uploadPackage,
|
||||
auditMessage,
|
||||
auditTypeLabel,
|
||||
validateLegacy,
|
||||
validateNotice,
|
||||
visibleEndpointCount: visibleEndpointCount.value,
|
||||
@@ -285,7 +320,7 @@ const viewContext = computed(() => ({
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json");
|
||||
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
||||
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -311,11 +346,12 @@ function toggleAutoRefresh() {
|
||||
autoRefreshPaused.value = !autoRefreshPaused.value;
|
||||
}
|
||||
|
||||
function setToast(message: string) {
|
||||
toast.value = message;
|
||||
window.setTimeout(() => {
|
||||
if (toast.value === message) toast.value = "";
|
||||
}, 4200);
|
||||
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>) {
|
||||
@@ -324,7 +360,7 @@ async function guarded(task: () => Promise<void>) {
|
||||
await task();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.value = message;
|
||||
setToast(message, "error");
|
||||
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
|
||||
navigate("/admin/login");
|
||||
}
|
||||
@@ -492,6 +528,38 @@ async function loadLegacy(name: LegacyName) {
|
||||
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 || {});
|
||||
}
|
||||
|
||||
function onPackageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
uploadDraft.file = input.files?.[0] || null;
|
||||
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));
|
||||
await api("/api/admin/releases/packages", { method: "POST", body: form, headers: {} });
|
||||
uploadDraft.file = null;
|
||||
uploadDraft.notes = "";
|
||||
setToast("发布包已上传并放入下载目录");
|
||||
await loadReleases();
|
||||
});
|
||||
}
|
||||
|
||||
async function validateLegacy(name: LegacyName) {
|
||||
@@ -506,6 +574,7 @@ async function validateLegacy(name: LegacyName) {
|
||||
|
||||
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 }),
|
||||
@@ -513,6 +582,7 @@ async function saveLegacy(name: LegacyName) {
|
||||
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 = "";
|
||||
setToast("兼容 JSON 已保存并发布到旧路径");
|
||||
});
|
||||
@@ -527,10 +597,110 @@ async function restoreLegacy(name: LegacyName, revisionId: number) {
|
||||
legacyDocuments[name] = data.document;
|
||||
legacyDrafts[name].raw = data.document.raw;
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||
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),
|
||||
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.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 removeItem(list: any[], index: number) {
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
const data = await api<{ catalog: any }>("/api/admin/sources");
|
||||
sources.value = data.catalog || { categories: [] };
|
||||
@@ -639,7 +809,7 @@ function endpointStatus(item: any) {
|
||||
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 (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
|
||||
if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
|
||||
return "neutral";
|
||||
}
|
||||
@@ -651,6 +821,7 @@ function objectEntries(value: Record<string, number>) {
|
||||
function labelStatus(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
ok: "正常",
|
||||
redirected: "重定向健康",
|
||||
error: "错误",
|
||||
degraded: "降级",
|
||||
unknown: "未知",
|
||||
@@ -662,6 +833,35 @@ function labelStatus(value: string) {
|
||||
return labels[value] || 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 formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
@@ -683,6 +883,30 @@ 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(() => {
|
||||
void load();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
@@ -696,6 +920,10 @@ onUnmounted(() => {
|
||||
</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>
|
||||
@@ -721,7 +949,6 @@ onUnmounted(() => {
|
||||
</label>
|
||||
<button class="btn primary full" type="submit">登录</button>
|
||||
</form>
|
||||
<p v-if="toast" class="notice">{{ toast }}</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -760,8 +987,6 @@ onUnmounted(() => {
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user