更新UI
This commit is contained in:
@@ -8,8 +8,11 @@
|
||||
"name": "ymhut-unified-portal",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.6.4"
|
||||
@@ -486,6 +489,74 @@
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@primeuix/styled": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/styled/-/styled-0.7.4.tgz",
|
||||
"integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/styles": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/styles/-/styles-2.0.3.tgz",
|
||||
"integrity": "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/themes": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/themes/-/themes-1.2.5.tgz",
|
||||
"integrity": "sha512-n3YkwJrHQaEESc/D/A/iD815sxp8cKnmzscA6a8Tm8YvMtYU32eCahwLLe6h5rywghVwxASWuG36XBgISYOIjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@primeuix/utils": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/@primeuix/utils/-/utils-0.6.4.tgz",
|
||||
"integrity": "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/core": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@primevue/core/-/core-4.5.5.tgz",
|
||||
"integrity": "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4",
|
||||
"@primeuix/utils": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@primevue/icons": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/@primevue/icons/-/icons-4.5.5.tgz",
|
||||
"integrity": "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/utils": "^0.6.2",
|
||||
"@primevue/core": "4.5.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||
@@ -1153,6 +1224,28 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/primeicons": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/primeicons/-/primeicons-7.0.0.tgz",
|
||||
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/primevue": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmmirror.com/primevue/-/primevue-4.5.5.tgz",
|
||||
"integrity": "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@primeuix/styled": "^0.7.4",
|
||||
"@primeuix/styles": "^2.0.3",
|
||||
"@primeuix/utils": "^0.6.2",
|
||||
"@primevue/core": "4.5.5",
|
||||
"@primevue/icons": "4.5.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.6.4"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user