import { computed, ref } from "vue"; const bootstrap = ref(null); const releases = ref(null); const sources = ref(null); const notices = ref([]); const loading = ref(false); const error = ref(""); const loadedAt = ref(""); const requestState = ref>({ bootstrap: "idle", releases: "idle", sources: "idle", notices: "idle", }); let loaded = false; const endpointLabels: Record = { "/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]}`; }