167 lines
6.3 KiB
TypeScript
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]}`;
|
|
}
|