This commit is contained in:
QWQLwToo
2026-07-12 09:12:12 +08:00
committed by admin_gitea
parent d44200fde1
commit 5e4355700f
43 changed files with 2797 additions and 709 deletions
@@ -2,6 +2,11 @@
import { onMounted } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network } from "lucide-vue-next";
import Button from "primevue/button";
import Message from "primevue/message";
import ProgressBar from "primevue/progressbar";
import Tag from "primevue/tag";
import Toolbar from "primevue/toolbar";
import { usePortalState } from "./state";
const route = useRoute();
@@ -20,21 +25,38 @@ onMounted(() => state.load());
<template>
<main class="portal-shell">
<nav class="topnav">
<RouterLink class="brand" to="/">
<span><img :src="state.branding.value.siteIconUrl" :alt="state.branding.value.developerName" /></span>
<strong>{{ state.branding.value.developerName }}</strong>
</RouterLink>
<div class="nav-links">
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
<component :is="item.icon" :size="15" />{{ item.label }}
<Toolbar class="topnav">
<template #start>
<RouterLink class="brand" to="/">
<span><img :src="state.logoUrl.value" :alt="state.branding.value.siteName" /></span>
<strong>{{ state.branding.value.siteName }}</strong>
</RouterLink>
</div>
</nav>
</template>
<template #center>
<nav class="nav-links" aria-label="门户导航">
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
<component :is="item.icon" :size="15" />{{ item.label }}
</RouterLink>
</nav>
</template>
<template #end>
<Button size="small" severity="secondary" text rounded @click="state.refresh">
<Activity :size="15" />刷新
</Button>
</template>
</Toolbar>
<p v-if="state.error.value" class="state-banner error">服务状态读取失败{{ state.error.value }}</p>
<p v-else-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取客户端公开状态...</p>
<p v-else-if="state.loadedAt.value" class="state-banner ready">公开状态已更新:{{ state.loadedAt.value.slice(0, 19).replace("T", " ") }}</p>
<ProgressBar v-if="state.loading.value" mode="indeterminate" class="loading-bar" />
<Message v-if="state.error.value" severity="error" :closable="false" class="state-message">
公开状态读取失败{{ state.error.value }}
</Message>
<Message v-else-if="state.failedRequests.value.length" severity="warn" :closable="false" class="state-message">
部分接口暂时不可用{{ state.failedRequests.value.map((item) => item.label).join("、") }}
</Message>
<div v-else-if="state.loadedAt.value" class="state-ready">
<Tag severity="success" value="公开状态已更新" />
<span>{{ state.loadedAt.value.slice(0, 19).replace("T", " ") }}</span>
</div>
<RouterView />
</main>
@@ -0,0 +1,59 @@
export type Branding = {
siteName: string;
portalTitle: string;
portalSubtitle: string;
adminTitle: string;
adminSubtitle: string;
siteIconUrl: string;
logoUrl: string;
developerAvatarUrl: string;
developerName: string;
feedbackEmail: string;
};
export const defaultBranding: Branding = {
siteName: "YMhut Box",
portalTitle: "YMhut Box 统一服务门户",
portalSubtitle: "统一发布、反馈与接口源状态门户",
adminTitle: "YMhut 统一管理台",
adminSubtitle: "发布、反馈、接口源与系统运维",
siteIconUrl: "/assets/favicon.ico",
logoUrl: "/assets/favicon.ico",
developerAvatarUrl: "/assets/developer-avatar.png",
developerName: "YMhut",
feedbackEmail: "support@ymhut.cn",
};
function valueOf(source: any, key: keyof Branding) {
const value = source?.[key];
return typeof value === "string" && value.trim() ? value.trim() : "";
}
export function normalizeBranding(source: any): Branding {
const siteName = valueOf(source, "siteName") || defaultBranding.siteName;
const siteIconUrl = valueOf(source, "siteIconUrl") || defaultBranding.siteIconUrl;
return {
siteName,
portalTitle: valueOf(source, "portalTitle") || `${siteName} 统一服务门户`,
portalSubtitle: valueOf(source, "portalSubtitle") || defaultBranding.portalSubtitle,
adminTitle: valueOf(source, "adminTitle") || defaultBranding.adminTitle,
adminSubtitle: valueOf(source, "adminSubtitle") || defaultBranding.adminSubtitle,
siteIconUrl,
logoUrl: valueOf(source, "logoUrl") || siteIconUrl,
developerAvatarUrl: valueOf(source, "developerAvatarUrl") || defaultBranding.developerAvatarUrl,
developerName: valueOf(source, "developerName") || defaultBranding.developerName,
feedbackEmail: valueOf(source, "feedbackEmail") || defaultBranding.feedbackEmail,
};
}
export function applyDocumentBranding(branding: Branding, surface: "portal" | "admin" = "portal") {
if (typeof document === "undefined") return;
document.title = surface === "admin" ? branding.adminTitle : branding.portalTitle;
let icon = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
if (!icon) {
icon = document.createElement("link");
icon.rel = "icon";
document.head.appendChild(icon);
}
icon.href = branding.siteIconUrl;
}
@@ -1,5 +1,8 @@
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import PrimeVue from "primevue/config";
import Aura from "@primeuix/themes/aura";
import "primeicons/primeicons.css";
import App from "./App.vue";
import OverviewPage from "./pages/OverviewPage.vue";
import ReleasesPage from "./pages/ReleasesPage.vue";
@@ -19,4 +22,36 @@ const router = createRouter({
],
});
createApp(App).use(router).mount("#app");
createApp(App)
.use(router)
.use(PrimeVue, {
ripple: true,
theme: {
preset: Aura,
options: {
darkModeSelector: ".portal-dark",
},
},
locale: {
accept: "确定",
reject: "取消",
startsWith: "开始于",
contains: "包含",
notContains: "不包含",
endsWith: "结束于",
equals: "等于",
notEquals: "不等于",
noFilter: "无筛选",
lt: "小于",
lte: "小于等于",
gt: "大于",
gte: "大于等于",
dateIs: "日期为",
dateIsNot: "日期不为",
dateBefore: "早于",
dateAfter: "晚于",
clear: "清除",
apply: "应用",
},
})
.mount("#app");
@@ -1,4 +1,9 @@
<script setup lang="ts">
import Accordion from "primevue/accordion";
import AccordionContent from "primevue/accordioncontent";
import AccordionHeader from "primevue/accordionheader";
import AccordionPanel from "primevue/accordionpanel";
const items = [
{ title: "旧版更新能力", body: "旧客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
{ title: "旧版媒体源能力", body: "媒体源目录继续保留旧字段结构,客户端无需修改即可读取。" },
@@ -14,10 +19,12 @@ const items = [
<p>新旧客户端共用 update.ymhut.cn门户只展示能力说明具体接口由客户端自动选择</p>
</section>
<section class="content-grid">
<article v-for="item in items" :key="item.title" class="panel compat-card">
<h2>{{ item.title }}</h2>
<p>{{ item.body }}</p>
</article>
</section>
<Accordion value="0" class="compat-accordion">
<AccordionPanel v-for="(item, index) in items" :key="item.title" :value="String(index)">
<AccordionHeader>{{ item.title }}</AccordionHeader>
<AccordionContent>
<p>{{ item.body }}</p>
</AccordionContent>
</AccordionPanel>
</Accordion>
</template>
@@ -1,24 +1,112 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { MessageSquareText } from "lucide-vue-next";
import { MailCheck, MessageSquareText, Search } from "lucide-vue-next";
import Button from "primevue/button";
import Card from "primevue/card";
import InputGroup from "primevue/inputgroup";
import InputText from "primevue/inputtext";
import Message from "primevue/message";
import Tag from "primevue/tag";
const feedbackCode = ref("");
const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=${encodeURIComponent(feedbackCode.value.trim())}` : "/?api=status&code=");
const querying = ref(false);
const queryError = ref("");
const feedbackStatus = ref<any>(null);
const normalizedCode = computed(() => feedbackCode.value.trim());
async function queryFeedback() {
queryError.value = "";
feedbackStatus.value = null;
if (!normalizedCode.value) {
queryError.value = "请输入反馈码后再查询。";
return;
}
querying.value = true;
try {
const res = await fetch(`/?api=status&code=${encodeURIComponent(normalizedCode.value)}`, { headers: { Accept: "application/json" } });
const data = await res.json().catch(() => null);
if (!res.ok || !data) throw new Error(data?.message || `查询失败,HTTP ${res.status}`);
if (data.ok === false) throw new Error(data.message || "未查询到该反馈码。");
feedbackStatus.value = data.feedback || data.ticket || data;
} catch (error) {
queryError.value = error instanceof Error ? error.message : String(error || "查询失败");
} finally {
querying.value = false;
}
}
function statusSeverity(value: string) {
const status = String(value || "").toLowerCase();
if (["closed", "sent", "resolved", "done"].includes(status)) return "success";
if (["processing", "pending", "new"].includes(status)) return "warn";
if (["failed", "error", "rejected"].includes(status)) return "danger";
return "secondary";
}
function fieldValue(...values: unknown[]) {
return values.find((value) => value !== undefined && value !== null && String(value).trim() !== "") || "-";
}
function formatDate(value: unknown) {
if (!value) return "-";
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", { hour12: false });
}
</script>
<template>
<section class="page-heading">
<p class="eyebrow">Feedback</p>
<h1>反馈查询</h1>
<p>已有反馈可通过反馈码查询公开处理状态</p>
<p>输入反馈码即可查看公开处理进度回复和邮件状态结果会在当前页面渲染展示</p>
</section>
<section class="panel feedback-panel">
<h2>查询反馈状态</h2>
<div class="feedback-box">
<input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
</div>
<p class="muted">状态查询只返回公开进度公开回复和接收时间不展示后台内部处理记录</p>
</section>
<Card class="panel feedback-panel">
<template #title>查询反馈状态</template>
<template #content>
<InputGroup class="feedback-box" @keyup.enter="queryFeedback">
<InputText v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
<Button rounded :loading="querying" @click="queryFeedback">
<Search :size="18" />查询状态
</Button>
</InputGroup>
<Message v-if="queryError" severity="warn" :closable="false">{{ queryError }}</Message>
<p class="muted">状态查询只展示公开进度公开回复和接收时间不展示后台内部处理记录</p>
</template>
</Card>
<Card v-if="feedbackStatus" class="panel feedback-result">
<template #title>
<span class="feedback-result-title"><MessageSquareText :size="20" />{{ fieldValue(feedbackStatus.code, normalizedCode) }}</span>
</template>
<template #content>
<div class="feedback-status-head">
<Tag :value="fieldValue(feedbackStatus.statusLabel, feedbackStatus.status)" :severity="statusSeverity(feedbackStatus.status)" rounded />
<Tag :value="'优先级:' + fieldValue(feedbackStatus.priorityLabel, feedbackStatus.priority)" severity="info" rounded />
<Tag :value="'分类:' + fieldValue(feedbackStatus.categoryLabel, feedbackStatus.category)" severity="secondary" rounded />
</div>
<div class="feedback-fields">
<article>
<span>状态说明</span>
<strong>{{ fieldValue(feedbackStatus.statusDetail, feedbackStatus.detail, feedbackStatus.message, "已收到反馈,正在处理中。") }}</strong>
</article>
<article>
<span>公开回复</span>
<strong>{{ fieldValue(feedbackStatus.publicReply, feedbackStatus.reply, "暂无公开回复。") }}</strong>
</article>
<article>
<span>接收时间</span>
<strong>{{ formatDate(fieldValue(feedbackStatus.receivedAt, feedbackStatus.createdAt, feedbackStatus.created_at)) }}</strong>
</article>
<article>
<span>更新时间</span>
<strong>{{ formatDate(fieldValue(feedbackStatus.updatedAt, feedbackStatus.updated_at)) }}</strong>
</article>
<article>
<span>邮件状态</span>
<strong><MailCheck :size="16" />{{ fieldValue(feedbackStatus.mailStatus, feedbackStatus.emailStatus, feedbackStatus.mail_status, "未公开") }}</strong>
</article>
</div>
</template>
</Card>
</template>
@@ -1,5 +1,9 @@
<script setup lang="ts">
import { Activity, ArrowDownToLine, Box, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
import Button from "primevue/button";
import Card from "primevue/card";
import Tag from "primevue/tag";
import { RouterLink } from "vue-router";
import { usePortalState } from "../state";
const state = usePortalState();
@@ -9,58 +13,72 @@ const state = usePortalState();
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">update.ymhut.cn</p>
<h1>统一发布反馈与接口源状态门户</h1>
<p>统一展示 YMhut Box 的发布状态反馈入口接口源可用性与版本日志新版客户端动态读取服务配置旧客户端兼容能力继续保留</p>
<h1>{{ state.portalTitle.value }}</h1>
<p>{{ state.portalSubtitle.value }}新版客户端动态读取服务配置旧客户端兼容能力继续保留</p>
<div class="actions">
<a v-if="state.downloadUrl.value" class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
<RouterLink v-else class="button primary" to="/releases"><ArrowDownToLine :size="18" />查看发布状态</RouterLink>
<RouterLink class="button" to="/sources"><ShieldCheck :size="18" />查看接口状态</RouterLink>
<RouterLink class="button" to="/compatibility">兼容说明</RouterLink>
<Button v-if="state.downloadUrl.value" as="a" :href="state.downloadUrl.value" severity="contrast" rounded>
<ArrowDownToLine :size="18" />下载最新版本
</Button>
<RouterLink v-else class="p-button p-button-contrast p-button-rounded" to="/releases">
<ArrowDownToLine :size="18" />查看发布状态
</RouterLink>
<RouterLink class="p-button p-button-secondary p-button-rounded p-button-outlined" to="/sources">
<ShieldCheck :size="18" />查看接口状态
</RouterLink>
<RouterLink class="p-button p-button-secondary p-button-rounded p-button-text" to="/compatibility">兼容说明</RouterLink>
</div>
<div class="hero-tags">
<span>Legacy JSON 兼容</span>
<span>接口健康检测</span>
<span>反馈状态追踪</span>
<Tag value="Legacy JSON 兼容" severity="info" />
<Tag value="接口健康检测" severity="success" />
<Tag value="反馈状态追踪" severity="secondary" />
</div>
<p v-if="state.error.value && !state.hasPartialData.value" class="empty strong">暂时无法读取公开客户端接口请稍后刷新</p>
</div>
<aside class="release-card">
<span class="live-dot">服务在线</span>
<span>当前版本</span>
<strong>{{ state.appVersion.value }}</strong>
<p>{{ state.latestNotice.value?.title || state.releases.value?.title || "服务已启动,等待发布数据同步。" }}</p>
<div class="release-meta">
<span>{{ state.packages.value.length }} 个发布包</span>
<span>{{ state.sourceCount.value }} 接口源</span>
</div>
</aside>
<Card class="release-card">
<template #content>
<Tag class="live-dot" value="服务在线" severity="success" />
<span>当前版本</span>
<strong>{{ state.appVersion.value }}</strong>
<p>{{ state.latestNotice.value?.title || state.releases.value?.title || "服务已启动,等待发布数据同步。" }}</p>
<div class="release-meta">
<span>{{ state.packages.value.length }} 发布包</span>
<span>{{ state.sourceCount.value }} 个接口源</span>
</div>
</template>
</Card>
</section>
<section class="metric-grid">
<article class="metric"><Box :size="20" /><span>服务版本</span><strong>{{ state.serviceVersion.value }}</strong></article>
<article class="metric"><Network :size="20" /><span>可见接口源</span><strong>{{ state.sourceCount.value }}</strong></article>
<article class="metric"><HeartPulse :size="20" /><span>健康接口</span><strong>{{ state.healthyCount.value }}</strong></article>
<article class="metric"><Activity :size="20" /><span>可用率</span><strong>{{ state.availability.value }}%</strong></article>
<Card class="metric"><template #content><Box :size="20" /><span>服务版本</span><strong>{{ state.serviceVersion.value }}</strong></template></Card>
<Card class="metric"><template #content><Network :size="20" /><span>可见接口源</span><strong>{{ state.sourceCount.value }}</strong></template></Card>
<Card class="metric"><template #content><HeartPulse :size="20" /><span>健康接口</span><strong>{{ state.healthyCount.value }}</strong></template></Card>
<Card class="metric"><template #content><Activity :size="20" /><span>可用率</span><strong>{{ state.availability.value }}%</strong></template></Card>
</section>
<section class="content-grid">
<article class="panel">
<div class="section-head"><h2>服务入口</h2><span class="badge good">运行中</span></div>
<div class="route-list">
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包版本公告和 update-notice 日志</span></RouterLink>
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源数据源和动态客户端接口状态</span></RouterLink>
<RouterLink to="/feedback"><strong>反馈查询</strong><span>按反馈码查看旧客户端反馈处理状态</span></RouterLink>
</div>
</article>
<article class="panel">
<div class="section-head"><h2>最新版本日志</h2><RouterLink to="/releases">查看全部</RouterLink></div>
<div v-if="state.latestNotice.value" class="notice-card">
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
</div>
<p v-else-if="state.loading.value" class="empty">正在读取版本日志...</p>
<p v-else class="empty">暂无可展示的版本日志</p>
</article>
<Card class="panel">
<template #title>服务入口</template>
<template #subtitle>运行中</template>
<template #content>
<div class="route-list">
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包版本公告和 update-notice 日志</span></RouterLink>
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源数据源和动态客户端接口状态</span></RouterLink>
<RouterLink to="/feedback"><strong>反馈查询</strong><span>按反馈码查看旧客户端反馈处理状态</span></RouterLink>
</div>
</template>
</Card>
<Card class="panel">
<template #title>最新版本日志</template>
<template #subtitle><RouterLink to="/releases">查看全部</RouterLink></template>
<template #content>
<div v-if="state.latestNotice.value" class="notice-card">
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
</div>
<p v-else-if="state.loading.value" class="empty">正在读取版本日志...</p>
<p v-else class="empty">暂无可展示的版本日志</p>
</template>
</Card>
</section>
</template>
@@ -1,51 +1,129 @@
<script setup lang="ts">
import { BookOpenText } from "lucide-vue-next";
import { computed } from "vue";
import { BookOpenText, Download, PackageCheck, Sparkles } from "lucide-vue-next";
import Button from "primevue/button";
import Card from "primevue/card";
import Column from "primevue/column";
import DataTable from "primevue/datatable";
import Skeleton from "primevue/skeleton";
import Tag from "primevue/tag";
import { usePortalState } from "../state";
const state = usePortalState();
const latestPackage = computed(() => state.packages.value[0] || null);
</script>
<template>
<section class="page-heading">
<p class="eyebrow">Releases</p>
<h1>发布版本</h1>
<p>展示客户端可见的发布包下载入口和版本日志</p>
<p>聚合展示最新版本发布包平台架构和版本日志下载入口保持清晰稳定</p>
</section>
<section class="content-grid">
<article class="panel wide">
<div class="section-head"><h2>发布包</h2><span class="badge">{{ state.packages.value.length }} 个可用包</span></div>
<table>
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th></th></tr></thead>
<tbody>
<tr v-for="pkg in state.packages.value" :key="pkg.fileName || pkg.url">
<td>{{ pkg.fileName || pkg.name || "-" }}</td>
<td>{{ pkg.version || state.appVersion.value }}</td>
<td>{{ pkg.platform || "-" }}/{{ pkg.arch || "-" }}</td>
<td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
<td><a :href="pkg.url || state.downloadUrl.value">下载</a></td>
</tr>
<tr v-if="state.loading.value"><td colspan="5">正在读取发布包...</td></tr>
<tr v-else-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包</td></tr>
</tbody>
</table>
<p v-if="state.error.value && state.packages.value.length === 0" class="empty">发布信息读取失败{{ state.error.value }}</p>
</article>
<article class="panel wide">
<div class="section-head"><h2>版本日志</h2><span class="badge good">自动同步</span></div>
<div class="notice-list">
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
<BookOpenText :size="22" />
<Card class="panel release-summary">
<template #title>最新可用版本</template>
<template #content>
<div class="release-summary-body">
<Sparkles :size="24" />
<div>
<strong>{{ notice.title || notice.version }}</strong>
<p>{{ notice.message || notice.releaseNotes || notice.release_notes || "暂无详细说明。" }}</p>
<span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span>
<span>当前版本</span>
<strong>{{ state.appVersion.value }}</strong>
<p>{{ state.latestNotice.value?.title || state.latestNotice.value?.message || "暂无版本摘要。" }}</p>
</div>
</section>
</div>
<div class="release-summary-actions">
<Tag :value="state.packages.value.length + ' 个发布包'" severity="info" rounded />
<Button v-if="state.downloadUrl.value" as="a" rounded :href="state.downloadUrl.value">
<Download :size="17" />下载最新版
</Button>
</div>
</template>
</Card>
<Card class="panel release-summary">
<template #title>首选安装包</template>
<template #content>
<div v-if="latestPackage" class="package-feature">
<PackageCheck :size="24" />
<div>
<strong>{{ latestPackage.fileName || latestPackage.name || "发布包" }}</strong>
<p>{{ latestPackage.platform || "通用平台" }} / {{ latestPackage.arch || "通用架构" }} · {{ state.formatBytes(latestPackage.sizeBytes || latestPackage.size || 0) }}</p>
</div>
</div>
<p v-else class="empty">暂无首选安装包</p>
</template>
</Card>
<Card class="panel wide">
<template #title>发布包</template>
<template #subtitle>{{ state.packages.value.length }} 个可用包</template>
<template #content>
<div v-if="state.packages.value.length" class="package-grid">
<article v-for="pkg in state.packages.value" :key="pkg.url || pkg.fileName || pkg.name" class="package-card">
<div>
<Tag :value="pkg.version || state.appVersion.value" severity="info" rounded />
<h3>{{ pkg.fileName || pkg.name || "发布包" }}</h3>
<p>{{ pkg.platform || "通用平台" }} / {{ pkg.arch || "通用架构" }}</p>
</div>
<div class="package-meta">
<span>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</span>
<span>{{ state.formatDateTime(pkg.updatedAt || pkg.updated_at || pkg.createdAt || pkg.created_at || "") }}</span>
</div>
<Button as="a" rounded size="small" :href="pkg.url || state.downloadUrl.value">
<Download :size="15" />下载
</Button>
</article>
</div>
<DataTable :value="state.packages.value" size="small" stripedRows responsiveLayout="scroll">
<Column header="文件">
<template #body="{ data }">{{ data.fileName || data.name || "-" }}</template>
</Column>
<Column header="版本">
<template #body="{ data }"><Tag :value="data.version || state.appVersion.value" severity="info" /></template>
</Column>
<Column header="平台">
<template #body="{ data }">{{ data.platform || "-" }}/{{ data.arch || "-" }}</template>
</Column>
<Column header="大小">
<template #body="{ data }">{{ state.formatBytes(data.sizeBytes || data.size || 0) }}</template>
</Column>
<Column header="">
<template #body="{ data }">
<Button as="a" size="small" rounded :href="data.url || state.downloadUrl.value">
<Download :size="15" />下载
</Button>
</template>
</Column>
<template #empty>
<Skeleton v-if="state.loading.value" height="2.5rem" />
<span v-else>暂无可见发布包</span>
</template>
</DataTable>
<p v-if="state.error.value && state.packages.value.length === 0" class="empty">发布信息读取失败{{ state.error.value }}</p>
</template>
</Card>
<Card class="panel wide">
<template #title>版本日志</template>
<template #subtitle>自动同步</template>
<template #content>
<div v-if="state.notices.value.length" class="release-log-grid">
<section v-for="notice in state.notices.value" :key="notice.id || notice.version || notice.title" class="notice-card release-log-card">
<span class="timeline-marker"><BookOpenText :size="14" /></span>
<div>
<div class="release-log-head">
<Tag :value="notice.version || '版本'" severity="info" rounded />
<span>{{ state.formatDateTime(notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "") }}</span>
</div>
<strong>{{ notice.title || notice.version || "版本更新" }}</strong>
<p>{{ notice.message || notice.releaseNotes || notice.release_notes || "暂无详细说明。" }}</p>
</div>
</section>
</div>
<p v-if="state.loading.value" class="empty">正在读取版本日志...</p>
<p v-else-if="state.notices.value.length === 0" class="empty">暂无版本日志</p>
</div>
</article>
</template>
</Card>
</section>
</template>
@@ -1,34 +1,109 @@
<script setup lang="ts">
import { CheckCircle2 } from "lucide-vue-next";
import { onMounted, onUnmounted } from "vue";
import { Activity, CheckCircle2, Gauge, TimerReset, WifiOff } from "lucide-vue-next";
import Card from "primevue/card";
import Column from "primevue/column";
import DataTable from "primevue/datatable";
import Message from "primevue/message";
import ProgressBar from "primevue/progressbar";
import Tag from "primevue/tag";
import { usePortalState } from "../state";
const state = usePortalState();
let refreshTimer: number | undefined;
onMounted(() => {
void state.refresh();
refreshTimer = window.setInterval(() => void state.refresh(), 20000);
});
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer);
});
</script>
<template>
<section class="page-heading">
<p class="eyebrow">Sources</p>
<h1>接口源健康</h1>
<p>客户端可见接口目录和最近健康状态汇总</p>
<p> 20 秒同步服务端健康检测结果展示每个接口独立的实时状态延迟和趋势</p>
</section>
<section class="panel wide">
<div class="section-head"><h2>接口源可用性</h2><span class="badge">{{ state.sourceCount.value }} 个接口源</span></div>
<div v-if="state.categories.value.length" class="source-board">
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
<div>
<h3>{{ cat.name || cat.id }}</h3>
<p>{{ cat.subcategories?.length || 0 }} 个数据源</p>
</div>
<div class="source-list">
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]">
<CheckCircle2 :size="13" />{{ src.name }}<small v-if="state.sourceStatus(src) === 'redirected'">重定向</small>
</span>
</div>
</section>
</div>
<p v-else-if="state.loading.value" class="empty">正在读取接口源目录...</p>
<p v-else-if="state.error.value" class="empty">接口源状态读取失败{{ state.error.value }}</p>
<p v-else class="empty">暂无客户端可见接口源</p>
</section>
<Card class="panel wide">
<template #title>接口源可用性</template>
<template #subtitle>
{{ state.sourceCount.value }} 个接口源{{ state.availability.value }}% 可用率
<span v-if="state.latestSourceCheckedAt.value"> · 最近检测 {{ state.formatDateTime(state.latestSourceCheckedAt.value) }}</span>
</template>
<template #content>
<div class="latency-kpis">
<article><Activity :size="18" /><span>可用接口</span><strong>{{ state.healthyCount.value }}/{{ state.sourceCount.value }}</strong></article>
<article><Gauge :size="18" /><span>平均延迟</span><strong>{{ state.averageLatencyMs.value }}ms</strong></article>
<article><TimerReset :size="18" /><span>P95 / 最大</span><strong>{{ state.p95LatencyMs.value }}ms / {{ state.maxLatencyMs.value }}ms</strong></article>
<article><WifiOff :size="18" /><span>异常 / 慢接口</span><strong>{{ state.abnormalSourceCount.value }}/{{ state.slowSourceCount.value }}</strong></article>
</div>
<ProgressBar :value="state.availability.value" class="availability-bar" />
<Message v-if="state.errors.value['/api/client/sources']" severity="warn" :closable="false" class="state-message-inline">
接口源刷新失败已保留上一次数据{{ state.errors.value['/api/client/sources'] }}
</Message>
<div v-if="state.categories.value.length" class="source-board">
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
<div>
<h3>{{ cat.name || cat.id }}</h3>
<p>{{ cat.subcategories?.length || 0 }} 个数据源</p>
</div>
<div class="source-list">
<Tag v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :severity="state.statusSeverity(state.sourceStatus(src))">
<CheckCircle2 :size="13" />{{ src.name }}
<small>{{ state.sourceLatency(src) ?? "-" }}ms</small>
<small v-if="state.sourceStatus(src) === 'redirected'">重定向</small>
</Tag>
</div>
</section>
</div>
<p v-else-if="state.loading.value" class="empty">正在读取接口源目录...</p>
<p v-else-if="state.error.value" class="empty">接口源状态读取失败{{ state.error.value }}</p>
<p v-else class="empty">暂无客户端可见接口源</p>
</template>
</Card>
<Card class="panel wide latency-panel">
<template #title>实时接口延迟表</template>
<template #subtitle>以服务端最近一次独立健康检测结果为准页面每 20 秒刷新</template>
<template #content>
<DataTable :value="state.latencyRows.value" size="small" stripedRows responsiveLayout="scroll" sortField="lastCheckedAt" :sortOrder="-1">
<Column field="name" header="接口">
<template #body="{ data }">
<div class="latency-name">
<strong>{{ data.name || data.sourceId || data.id }}</strong>
<span>{{ data.categoryName || data.categoryId || "未分类" }}</span>
</div>
</template>
</Column>
<Column field="status" header="状态">
<template #body="{ data }">
<Tag :severity="state.statusSeverity(data.status)" :value="data.status" rounded />
</template>
</Column>
<Column field="latencyMs" header="延迟">
<template #body="{ data }">
<strong :class="['latency-value', Number(data.latencyMs) >= 1500 ? 'slow' : '']">{{ data.latencyMs ?? "-" }}ms</strong>
</template>
</Column>
<Column field="lastCheckedAt" header="最近检测">
<template #body="{ data }">{{ state.formatDateTime(data.lastCheckedAt) }}</template>
</Column>
<Column header="延迟趋势">
<template #body="{ data }">
<svg class="sparkline" viewBox="0 0 120 34" role="img" aria-label="接口延迟趋势">
<polyline :points="state.sparklinePoints(data)" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
</Column>
<template #empty>
<span>暂无接口延迟数据</span>
</template>
</DataTable>
</template>
</Card>
</template>
+126 -10
View File
@@ -1,4 +1,5 @@
import { computed, ref } from "vue";
import { applyDocumentBranding, normalizeBranding } from "./branding";
const bootstrap = ref<any>(null);
const releases = ref<any>(null);
@@ -6,6 +7,7 @@ const sources = ref<any>(null);
const notices = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const errors = ref<Record<string, string>>({});
const loadedAt = ref("");
const requestState = ref<Record<string, "idle" | "loading" | "ready" | "error">>({
bootstrap: "idle",
@@ -45,20 +47,48 @@ 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 sourceRows = computed(() => categories.value.flatMap((cat: any) => (cat.subcategories || []).map((src: any) => ({
...src,
categoryId: cat.id || cat.categoryId || "",
categoryName: cat.name || cat.id || cat.categoryId || "未分类",
status: sourceStatus(src),
latencyMs: sourceLatency(src),
lastCheckedAt: sourceLastChecked(src),
lastError: sourceError(src),
history: sourceHistory(src),
}))));
const sourceCount = computed(() => sourceRows.value.length);
const healthyCount = computed(() => sourceRows.value.filter((item: any) => ["ok", "redirected"].includes(item.status)).length);
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
const latencyRows = computed(() => sourceRows.value.slice().sort((a: any, b: any) => {
const left = a.lastCheckedAt || "";
const right = b.lastCheckedAt || "";
if (left !== right) return right.localeCompare(left);
return Number(b.latencyMs ?? -1) - Number(a.latencyMs ?? -1);
}));
const latencyValues = computed(() => latencyRows.value.map((item: any) => Number(item.latencyMs)).filter((item: number) => Number.isFinite(item) && item >= 0));
const averageLatencyMs = computed(() => latencyValues.value.length ? Math.round(latencyValues.value.reduce((sum: number, item: number) => sum + item, 0) / latencyValues.value.length) : 0);
const maxLatencyMs = computed(() => latencyValues.value.length ? Math.max(...latencyValues.value) : 0);
const p95LatencyMs = computed(() => {
if (!latencyValues.value.length) return 0;
const sorted = latencyValues.value.slice().sort((a: number, b: number) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] || 0;
});
const slowSourceCount = computed(() => latencyRows.value.filter((item: any) => Number(item.latencyMs) >= 1500).length);
const abnormalSourceCount = computed(() => latencyRows.value.filter((item: any) => !["ok", "redirected"].includes(item.status)).length);
const latestSourceCheckedAt = computed(() => latencyRows.value.map((item: any) => item.lastCheckedAt).filter(Boolean).sort().pop() || "");
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 || "/assets/favicon.ico",
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "/assets/developer-avatar.png",
developerName: bootstrap.value?.branding?.developerName || "YMhut",
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
}));
const branding = computed(() => normalizeBranding(bootstrap.value?.branding));
const portalTitle = computed(() => branding.value.portalTitle);
const portalSubtitle = computed(() => branding.value.portalSubtitle);
const logoUrl = computed(() => branding.value.logoUrl || branding.value.siteIconUrl);
const failedRequests = computed(() => Object.entries(errors.value).map(([path, message]) => ({
path,
label: endpointLabels[path] || path,
message,
})));
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);
@@ -68,6 +98,7 @@ export function usePortalState() {
if (loaded && !force) return;
loading.value = true;
error.value = "";
errors.value = {};
requestState.value = { bootstrap: "loading", releases: "loading", sources: "loading", notices: "loading" };
try {
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
@@ -79,26 +110,31 @@ export function usePortalState() {
if (bootstrapData.status === "fulfilled") {
bootstrap.value = bootstrapData.value;
requestState.value.bootstrap = "ready";
applyDocumentBranding(branding.value, "portal");
} else {
requestState.value.bootstrap = "error";
errors.value["/api/client/bootstrap"] = failureMessage(bootstrapData.reason);
}
if (releaseData.status === "fulfilled") {
releases.value = releaseData.value;
requestState.value.releases = "ready";
} else {
requestState.value.releases = "error";
errors.value["/api/client/releases"] = failureMessage(releaseData.reason);
}
if (sourceData.status === "fulfilled") {
sources.value = sourceData.value;
requestState.value.sources = "ready";
} else {
requestState.value.sources = "error";
errors.value["/api/client/sources"] = failureMessage(sourceData.reason);
}
if (noticeData.status === "fulfilled") {
notices.value = noticeData.value.items || [];
requestState.value.notices = "ready";
} else {
requestState.value.notices = "error";
errors.value["/api/client/notices"] = failureMessage(noticeData.reason);
}
const firstFailure = [bootstrapData, releaseData, sourceData, noticeData].find((item) => item.status === "rejected") as PromiseRejectedResult | undefined;
if (firstFailure && !hasPartialData.value) error.value = failureMessage(firstFailure.reason);
@@ -107,6 +143,7 @@ export function usePortalState() {
} catch (err) {
error.value = failureMessage(err);
} finally {
applyDocumentBranding(branding.value, "portal");
loading.value = false;
}
}
@@ -118,26 +155,47 @@ export function usePortalState() {
notices,
loading,
error,
errors,
failedRequests,
loadedAt,
requestState,
packages,
categories,
latestNotice,
sourceRows,
latencyRows,
sourceCount,
healthyCount,
availability,
averageLatencyMs,
maxLatencyMs,
p95LatencyMs,
slowSourceCount,
abnormalSourceCount,
latestSourceCheckedAt,
downloadUrl,
appVersion,
serviceVersion,
branding,
portalTitle,
portalSubtitle,
logoUrl,
isReady,
hasPartialData,
releasesEmpty,
sourcesEmpty,
load,
refresh: () => load(true),
sourceStatus,
sourceLatency,
sourceLastChecked,
sourceError,
statusTone,
statusSeverity,
formatBytes,
formatDateTime,
sourceHistory,
sparklinePoints,
};
}
@@ -145,6 +203,41 @@ export function sourceStatus(item: any) {
return item.health?.status || item.lastStatus || "unknown";
}
export function sourceLatency(item: any) {
const value = firstFiniteNumber(item.health?.latencyMs, item.health?.latency_ms, item.lastLatencyMs, item.last_latency_ms, item.latencyMs, item.latency_ms);
return value === null ? null : value;
}
export function sourceLastChecked(item: any) {
return item.health?.lastCheckedAt || item.health?.last_checked_at || item.lastCheckedAt || item.last_checked_at || item.checkedAt || item.checked_at || "";
}
export function sourceError(item: any) {
return item.health?.lastError || item.health?.last_error || item.lastError || item.last_error || item.error || "";
}
export function sourceHistory(item: any) {
const history = item.health?.history || item.history || [];
return Array.isArray(history) ? history : [];
}
export function sparklinePoints(item: any) {
const values = sourceHistory(item)
.map((entry: any) => firstFiniteNumber(entry.latencyMs, entry.latency_ms, entry.latency))
.filter((entry: number | null): entry is number => entry !== null);
const series = values.length ? values : [sourceLatency(item) ?? 0];
const max = Math.max(1, ...series);
if (series.length === 1) {
const y = 30 - (series[0] / max) * 24;
return `0,${y.toFixed(1)} 120,${y.toFixed(1)}`;
}
return series.map((value: number, index: number) => {
const x = (index / (series.length - 1)) * 120;
const y = 30 - (value / max) * 24;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
}
export function statusTone(status: string) {
const value = String(status || "").toLowerCase();
if (["ok", "redirected", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
@@ -153,6 +246,14 @@ export function statusTone(status: string) {
return "neutral";
}
export function statusSeverity(status: string) {
const tone = statusTone(status);
if (tone === "good") return "success";
if (tone === "warn") return "warn";
if (tone === "bad") return "danger";
return "secondary";
}
export function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
@@ -164,3 +265,18 @@ export function formatBytes(value: number) {
}
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatDateTime(value: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
function firstFiniteNumber(...values: unknown[]) {
for (const value of values) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 0) return numeric;
}
return null;
}
@@ -1,470 +1,396 @@
:root {
color-scheme: light;
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
color: #172033;
background: #f5f7f4;
--ink: #172033;
--muted: #63718a;
--soft: #f5f7f4;
--panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff;
--line: rgba(112, 132, 170, 0.18);
--line-strong: rgba(94, 114, 158, 0.28);
--primary: #1f6f5b;
--primary-dark: #155241;
--accent: #d99227;
color: #020617;
background: #f8fafc;
--ink: #020617;
--muted: #64748b;
--panel: #ffffff;
--line: #e2e8f0;
--line-strong: #cbd5e1;
--primary: #0369a1;
--primary-dark: #0f172a;
--good: #059669;
--warn: #b7791f;
--warn: #b45309;
--bad: #dc2626;
--shadow: 0 22px 65px rgba(31, 48, 40, 0.12);
--shadow: 0 16px 44px rgba(15, 23, 42, 0.08);
--ease: cubic-bezier(.2,.8,.2,1);
}
* { box-sizing: border-box; }
html { min-width: 320px; }
body {
margin: 0;
min-width: 320px;
background: #f6f8f4;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: rgba(31, 111, 91, 0.025);
}
html { min-width: 320px; max-width: 100%; overflow-x: clip; }
body { margin: 0; min-width: 320px; background: #f8fafc; max-width: 100%; overflow-x: clip; }
a { color: inherit; }
button, input { font: inherit; }
button { cursor: pointer; }
h1, h2, h3, p { margin-top: 0; }
p { color: var(--muted); line-height: 1.7; font-size: 15px; }
.portal-shell {
position: relative;
min-height: 100dvh;
padding: 18px clamp(14px, 2.4vw, 28px) 48px;
overflow: hidden;
}
.topnav {
.topnav.p-toolbar {
position: sticky;
z-index: 20;
top: 14px;
min-height: 64px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
width: min(1180px, 100%);
margin: 0 auto 22px;
padding: 9px 12px;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 16px 42px rgba(62, 87, 130, 0.12);
min-height: 64px;
margin: 0 auto 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(18px);
gap: 12px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
min-height: 44px;
padding: 5px 12px 5px 6px;
border-radius: 999px;
text-decoration: none;
font-weight: 900;
}
.brand span {
width: 42px;
height: 42px;
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
background: #10231d;
box-shadow: 0 12px 26px rgba(31, 111, 91, 0.22);
}
.brand img {
width: 28px;
height: 28px;
object-fit: contain;
display: block;
}
.brand strong { letter-spacing: 0; }
.nav-links {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
border-radius: 12px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid var(--line);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
overflow: hidden;
padding: 5px;
}
.brand img { width: 100%; height: 100%; object-fit: contain; display: block; }
.brand strong { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-links { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
.nav-links a {
min-height: 38px;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 8px 12px;
color: #53627d;
padding: 7px 11px;
color: #475569;
text-decoration: none;
font-size: 14px;
font-weight: 800;
transition: transform 0.18s var(--ease), background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
transition: background-color 0.18s ease, color 0.18s ease;
}
.nav-links a:hover, .nav-links a.active {
color: var(--primary-dark);
background: rgba(31, 111, 91, 0.10);
transform: translateY(-1px);
.nav-links a:hover, .nav-links a.active { color: #075985; background: #e0f2fe; }
.loading-bar, .state-message, .state-ready {
width: min(1180px, 100%);
margin: 0 auto 14px;
}
.loading-bar { height: 4px; border-radius: 999px; overflow: hidden; }
.state-ready {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
.hero {
position: relative;
width: min(1180px, 100%);
min-height: 520px;
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
align-items: stretch;
border: 1px solid rgba(255, 255, 255, 0.70);
border-radius: 32px;
background: rgba(255, 255, 255, 0.88);
box-shadow: var(--shadow);
padding: clamp(28px, 5vw, 58px);
overflow: hidden;
}
.hero::after { content: none; }
.hero-copy {
position: relative;
z-index: 1;
max-width: 780px;
align-self: center;
}
.eyebrow {
margin: 0 0 12px;
color: var(--primary-dark);
margin: 0 0 10px;
color: var(--primary);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.14em;
letter-spacing: 0.1em;
}
h1 {
margin: 0 0 18px;
max-width: 900px;
color: #12213a;
font-size: clamp(36px, 6vw, 68px);
line-height: 1.02;
.hero {
width: min(1180px, 100%);
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) 350px;
gap: 18px;
align-items: stretch;
}
.hero-copy, .page-heading, .panel.p-card, .release-card.p-card, .metric.p-card {
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-copy {
min-height: 430px;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(28px, 5vw, 56px);
}
.hero-copy h1, .page-heading h1 {
margin: 0 0 16px;
max-width: 920px;
color: #0f172a;
font-size: clamp(34px, 5.2vw, 56px);
line-height: 1.04;
letter-spacing: 0;
}
h2, h3, p { margin-top: 0; }
p {
color: var(--muted);
line-height: 1.85;
font-size: 16px;
}
.hero-copy p { max-width: 690px; font-size: 17px; }
.actions {
.hero-copy p { max-width: 760px; font-size: 16px; }
.actions, .hero-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 28px;
}
.hero-tags {
display: flex;
flex-wrap: wrap;
.actions { margin-top: 24px; }
.hero-tags { margin-top: 18px; }
.actions .p-button, .actions a.p-button, a.p-button, .p-button {
gap: 8px;
margin-top: 22px;
}
.hero-tags span {
border: 1px solid rgba(31, 111, 91, 0.16);
border-radius: 999px;
padding: 7px 11px;
color: #355075;
background: rgba(255, 255, 255, 0.58);
font-size: 13px;
font-weight: 900;
}
.button {
min-height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid rgba(118, 137, 178, 0.22);
border-radius: 999px;
text-decoration: none;
background: rgba(255, 255, 255, 0.76);
color: #263856;
font-weight: 900;
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10);
border-radius: 999px;
transition: transform 0.18s var(--ease), box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease;
}
.button:hover {
.actions .p-button:hover, .actions a.p-button:hover, a.p-button:hover, .p-button:hover {
transform: translateY(-1px);
background: #fff;
box-shadow: 0 16px 36px rgba(65, 88, 140, 0.16);
}
.button.primary {
color: #fff;
border-color: transparent;
background: #10231d;
box-shadow: 0 16px 34px rgba(31, 111, 91, 0.24);
box-shadow: 0 10px 24px rgba(3, 105, 161, 0.16);
text-decoration: none;
}
.release-card, .panel, .metric {
border: 1px solid rgba(255, 255, 255, 0.74);
border-radius: 24px;
background: var(--panel);
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
backdrop-filter: blur(16px);
}
.release-card, .panel, .metric, .source-group, .notice-card, .route-list a {
transition: transform 0.22s var(--ease), border-color 0.22s ease, box-shadow 0.22s ease, background-color 0.22s ease;
}
.release-card:hover, .panel:hover, .metric:hover, .source-group:hover, .notice-card:hover, .route-list a:hover {
transform: translateY(-2px);
box-shadow: 0 18px 46px rgba(31, 48, 40, 0.13);
}
.release-card {
position: relative;
z-index: 1;
align-self: center;
.release-card.p-card {
align-self: stretch;
display: grid;
align-items: center;
}
.release-card .p-card-body, .release-card .p-card-content { height: 100%; }
.release-card .p-card-content {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
padding: 24px;
}
.release-card span { color: var(--muted); font-weight: 800; }
.release-card .live-dot {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--good);
border: 1px solid rgba(16, 185, 129, 0.22);
border-radius: 999px;
padding: 5px 9px;
background: rgba(209, 250, 229, 0.66);
}
.release-card .live-dot::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.13);
}
.release-card strong {
color: #0f172a;
display: block;
font-size: 44px;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.05;
overflow-wrap: anywhere;
}
.release-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 14px;
}
.release-meta span, .badge {
display: inline-flex;
align-items: center;
gap: 4px;
.release-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.release-meta span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
color: #44536e;
background: rgba(255, 255, 255, 0.68);
background: #f8fafc;
font-size: 12px;
font-weight: 900;
}
.metric-grid {
.metric-grid, .content-grid, .page-heading, .compat-accordion {
width: min(1180px, 100%);
margin: 18px auto 0;
margin-left: auto;
margin-right: auto;
}
.metric-grid {
margin-top: 18px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
gap: 12px;
}
.metric {
min-height: 132px;
display: grid;
.metric .p-card-content {
min-height: 116px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 18px;
}
.metric svg { color: var(--primary); }
.metric span { color: var(--muted); font-weight: 800; }
.metric strong { color: #15233b; font-size: 30px; overflow-wrap: anywhere; }
.metric strong { color: #0f172a; font-size: 28px; overflow-wrap: anywhere; }
.content-grid {
width: min(1180px, 100%);
margin: 18px auto 0;
margin-top: 18px;
display: grid;
grid-template-columns: 1.12fr 0.88fr;
grid-template-columns: 1.08fr 0.92fr;
gap: 18px;
}
.panel {
padding: 22px;
}
.panel.wide { grid-column: 1 / -1; }
.page-heading {
width: min(1180px, 100%);
margin: 0 auto 18px;
border: 1px solid rgba(255, 255, 255, 0.74);
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
box-shadow: var(--shadow);
margin-top: 0;
margin-bottom: 18px;
padding: clamp(24px, 4vw, 42px);
backdrop-filter: blur(16px);
}
.page-heading h1 { font-size: clamp(32px, 4.6vw, 52px); }
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.section-head h2 { margin: 0; color: #14223a; }
.section-head a {
color: var(--primary-dark);
font-weight: 900;
display: inline-flex;
align-items: center;
gap: 5px;
.page-heading h1 { font-size: clamp(30px, 4vw, 46px); }
.route-list { display: grid; gap: 10px; }
.route-list a {
display: grid;
gap: 4px;
border: 1px solid var(--line);
border-radius: 14px;
background: #f8fafc;
padding: 14px;
text-decoration: none;
transition: border-color 0.18s ease, background-color 0.18s ease;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
border-bottom: 1px solid rgba(112, 132, 170, 0.18);
padding: 11px 8px;
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.muted, .empty { color: var(--muted); }
.empty.strong { font-weight: 900; color: var(--bad); }
.route-list a:hover { border-color: var(--line-strong); background: #f1f5f9; }
.route-list strong { color: #0f172a; }
.route-list span { color: var(--muted); font-size: 13px; }
.notice-list { display: grid; gap: 12px; }
.notice-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
border: 1px solid rgba(112, 132, 170, 0.15);
border-radius: 20px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--line);
border-radius: 14px;
background: #f8fafc;
padding: 14px;
}
.notice-card svg { color: var(--primary); }
.notice-card strong { display: block; margin-bottom: 6px; overflow-wrap: anywhere; }
.notice-card p { margin-bottom: 8px; font-size: 14px; line-height: 1.65; }
.notice-card p { margin-bottom: 6px; font-size: 14px; }
.notice-card span { color: var(--muted); font-size: 13px; }
.empty { color: var(--muted); }
.empty.strong { color: var(--bad); font-weight: 900; }
.feedback-panel { max-width: 780px; margin: 0 auto; }
.feedback-box {
.availability-bar { margin-bottom: 16px; }
.latency-kpis, .package-grid, .feedback-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
input {
width: 100%;
min-height: 46px;
border: 1px solid rgba(112, 132, 170, 0.24);
.latency-kpis article, .package-card, .feedback-fields article, .release-summary-body, .package-feature {
min-width: 0;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
color: #172033;
padding: 10px 14px;
outline: none;
}
input:focus {
border-color: rgba(31, 111, 91, 0.58);
box-shadow: 0 0 0 4px rgba(31, 111, 91, 0.12);
}
.source-board {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.source-group {
border: 1px solid rgba(112, 132, 170, 0.16);
border-radius: 22px;
padding: 16px;
background: rgba(255, 255, 255, 0.72);
}
.source-group h3 { margin-bottom: 2px; color: #14223a; }
.source-group p { margin-bottom: 10px; font-size: 14px; }
.source-list {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.badge.good { color: var(--good); background: rgba(209, 250, 229, 0.78); border-color: rgba(16, 185, 129, 0.28); }
.badge.warn { color: var(--warn); background: rgba(254, 243, 199, 0.82); border-color: rgba(245, 158, 11, 0.28); }
.badge.bad { color: var(--bad); background: rgba(254, 226, 226, 0.82); border-color: rgba(239, 68, 68, 0.26); }
.route-list { display: grid; gap: 10px; }
.route-list a {
display: grid;
gap: 4px;
border: 1px solid rgba(112, 132, 170, 0.16);
border-radius: 18px;
background: rgba(255, 255, 255, 0.70);
background: #f8fafc;
padding: 14px;
text-decoration: none;
font-weight: 900;
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
}
.route-list a:hover {
transform: translateY(-1px);
border-color: rgba(31, 111, 91, 0.30);
background: rgba(255, 255, 255, 0.92);
.latency-kpis article {
display: grid;
gap: 6px;
}
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
.state-banner {
width: min(1180px, 100%);
margin: 12px auto;
border-radius: 999px;
padding: 10px 14px;
.latency-kpis svg, .release-summary svg, .timeline-marker svg { color: var(--primary); }
.latency-kpis span, .package-meta, .feedback-fields span, .release-summary-body span {
color: var(--muted);
font-size: 12px;
font-weight: 900;
}
.latency-kpis strong, .feedback-fields strong { color: #0f172a; overflow-wrap: anywhere; }
.state-message-inline { margin-bottom: 14px; }
.latency-name { display: grid; gap: 3px; }
.latency-name span, .table-note { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
.latency-value { color: var(--good); white-space: nowrap; }
.latency-value.slow { color: var(--warn); }
.sparkline {
width: 120px;
max-width: 100%;
height: 34px;
display: block;
color: var(--primary);
border: 1px solid var(--line);
border-radius: 10px;
background: linear-gradient(180deg, rgba(224, 242, 254, 0.72), rgba(255, 255, 255, 0.9));
padding: 4px;
}
.source-board { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.source-group {
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
background: #f8fafc;
}
.source-group h3 { margin-bottom: 2px; color: #0f172a; }
.source-group p { margin-bottom: 12px; font-size: 14px; }
.source-list { display: flex; gap: 7px; flex-wrap: wrap; }
.source-list .p-tag { gap: 4px; }
.release-summary .p-card-content { display: grid; gap: 14px; }
.release-summary-body, .package-feature {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: start;
margin-bottom: 0;
}
.release-summary-body strong {
display: block;
color: #0f172a;
font-size: 30px;
line-height: 1.08;
overflow-wrap: anywhere;
}
.release-summary-body p, .package-feature p { margin: 6px 0 0; }
.release-summary-actions, .package-meta, .feedback-status-head, .feedback-result-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 12px 30px rgba(65, 88, 140, 0.10);
}
.error { color: var(--bad); }
.loading { color: var(--muted); }
.ready { color: var(--good); }
.package-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.package-card {
display: grid;
gap: 12px;
align-content: space-between;
transition: transform 0.18s var(--ease), box-shadow 0.18s ease, border-color 0.18s ease;
}
.package-card:hover {
transform: translateY(-2px);
border-color: rgba(3, 105, 161, 0.28);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.10);
}
.package-card h3 { margin: 10px 0 4px; color: #0f172a; overflow-wrap: anywhere; }
.release-log-grid {
display: grid;
gap: 12px;
}
.release-log-card {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.release-log-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.release-log-head span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.timeline-marker {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
}
.feedback-panel, .feedback-result { max-width: 780px; margin: 0 auto 18px; }
.feedback-box { margin: 14px 0; }
.feedback-status-head { margin-bottom: 14px; }
.feedback-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.feedback-fields article { display: grid; gap: 6px; }
.feedback-fields strong {
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1.55;
}
.compat-accordion { margin-top: 12px; }
@media (max-width: 980px) {
.topnav {
position: static;
align-items: stretch;
flex-direction: column;
border-radius: 24px;
}
.nav-links { justify-content: flex-start; }
.hero, .content-grid, .metric-grid, .source-board { grid-template-columns: 1fr; }
.hero { min-height: auto; }
.feedback-box { grid-template-columns: 1fr; }
table { min-width: 680px; }
.panel { overflow-x: auto; }
@media (max-width: 960px) {
.topnav.p-toolbar { align-items: stretch; border-radius: 18px; }
.topnav .p-toolbar-group-center { width: 100%; }
.hero, .content-grid, .source-board, .package-grid, .latency-kpis, .feedback-fields { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.portal-shell { padding-inline: 10px; }
.hero, .page-heading { border-radius: 24px; padding: 24px; }
h1 { font-size: 36px; }
.actions .button { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
.portal-shell { padding-inline: 12px; }
.topnav.p-toolbar { top: 8px; }
.brand strong { max-width: 120px; }
.nav-links { justify-content: flex-start; }
.hero-copy { min-height: auto; padding: 24px; }
.metric-grid { grid-template-columns: 1fr; }
.feedback-box.p-inputgroup { display: grid; grid-template-columns: 1fr; }
}