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

167 lines
6.3 KiB
TypeScript

import { computed, ref } from "vue";
const bootstrap = ref<any>(null);
const releases = ref<any>(null);
const sources = ref<any>(null);
const notices = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const loadedAt = ref("");
const requestState = ref<Record<string, "idle" | "loading" | "ready" | "error">>({
bootstrap: "idle",
releases: "idle",
sources: "idle",
notices: "idle",
});
let loaded = false;
const endpointLabels: Record<string, string> = {
"/api/client/bootstrap": "客户端启动配置",
"/api/client/releases": "发布信息",
"/api/client/sources": "接口源目录",
"/api/client/notices": "版本日志",
};
async function fetchJSON(path: string) {
let res: Response;
try {
res = await fetch(path, { headers: { Accept: "application/json" } });
} catch {
throw new Error(`${endpointLabels[path] || path} 暂时无法连接`);
}
if (!res.ok) throw new Error(`${endpointLabels[path] || path} 返回 HTTP ${res.status}`);
try {
return await res.json();
} catch {
throw new Error(`${endpointLabels[path] || path} 返回内容不是有效 JSON`);
}
}
function failureMessage(reason: unknown) {
return reason instanceof Error ? reason.message : String(reason || "读取失败");
}
export function usePortalState() {
const packages = computed(() => releases.value?.packages || bootstrap.value?.release?.packages || []);
const categories = computed(() => sources.value?.categories || bootstrap.value?.sources?.categories || []);
const latestNotice = computed(() => notices.value[0] || releases.value?.latest_notice || bootstrap.value?.release?.latest_notice || null);
const sourceCount = computed(() => categories.value.reduce((total: number, cat: any) => total + (cat.subcategories?.length || 0), 0));
const healthyCount = computed(() => categories.value.reduce((total: number, cat: any) => {
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
}, 0));
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
const branding = computed(() => ({
siteIconUrl: bootstrap.value?.branding?.siteIconUrl || "https://img.ymhut.cn/file/1782108850041_icon.webp",
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
developerName: bootstrap.value?.branding?.developerName || "YMhut",
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
}));
const isReady = computed(() => loaded && !loading.value && !error.value);
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
const sourcesEmpty = computed(() => !loading.value && categories.value.length === 0);
async function load(force = false) {
if (loaded && !force) return;
loading.value = true;
error.value = "";
requestState.value = { bootstrap: "loading", releases: "loading", sources: "loading", notices: "loading" };
try {
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
fetchJSON("/api/client/bootstrap"),
fetchJSON("/api/client/releases"),
fetchJSON("/api/client/sources"),
fetchJSON("/api/client/notices"),
]);
if (bootstrapData.status === "fulfilled") {
bootstrap.value = bootstrapData.value;
requestState.value.bootstrap = "ready";
} else {
requestState.value.bootstrap = "error";
}
if (releaseData.status === "fulfilled") {
releases.value = releaseData.value;
requestState.value.releases = "ready";
} else {
requestState.value.releases = "error";
}
if (sourceData.status === "fulfilled") {
sources.value = sourceData.value;
requestState.value.sources = "ready";
} else {
requestState.value.sources = "error";
}
if (noticeData.status === "fulfilled") {
notices.value = noticeData.value.items || [];
requestState.value.notices = "ready";
} else {
requestState.value.notices = "error";
}
const firstFailure = [bootstrapData, releaseData, sourceData, noticeData].find((item) => item.status === "rejected") as PromiseRejectedResult | undefined;
if (firstFailure && !hasPartialData.value) error.value = failureMessage(firstFailure.reason);
loaded = true;
loadedAt.value = new Date().toISOString();
} catch (err) {
error.value = failureMessage(err);
} finally {
loading.value = false;
}
}
return {
bootstrap,
releases,
sources,
notices,
loading,
error,
loadedAt,
requestState,
packages,
categories,
latestNotice,
sourceCount,
healthyCount,
availability,
downloadUrl,
appVersion,
serviceVersion,
branding,
isReady,
hasPartialData,
releasesEmpty,
sourcesEmpty,
load,
sourceStatus,
statusTone,
formatBytes,
};
}
export function sourceStatus(item: any) {
return item.health?.status || item.lastStatus || "unknown";
}
export function statusTone(status: string) {
const value = String(status || "").toLowerCase();
if (["ok", "redirected", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
if (["degraded", "pending", "missing"].includes(value)) return "warn";
if (["error", "offline", "failed"].includes(value)) return "bad";
return "neutral";
}
export 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]}`;
}