继续更新 update 门户站点界面和功能
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 20:17:34 +08:00
parent f525e5f3ba
commit 2513eb2903
68 changed files with 5586 additions and 3195 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network, ShieldCheck } from "lucide-vue-next";
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network } from "lucide-vue-next";
import { usePortalState } from "./state";
const route = useRoute();
@@ -22,7 +22,7 @@ onMounted(() => state.load());
<main class="portal-shell">
<nav class="topnav">
<RouterLink class="brand" to="/">
<span><ShieldCheck :size="22" /></span>
<span><img src="/logo-44.png" alt="YMhut Box" /></span>
<strong>YMhut Box</strong>
</RouterLink>
<div class="nav-links">
@@ -30,11 +30,11 @@ onMounted(() => state.load());
<component :is="item.icon" :size="15" />{{ item.label }}
</RouterLink>
</div>
<a class="admin-link" href="/admin/login">控制台</a>
</nav>
<p v-if="state.error.value" class="state-banner error">部分状态读取失败{{ state.error.value }}</p>
<p v-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取服务状态...</p>
<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>
<RouterView />
</main>
@@ -1,9 +1,9 @@
<script setup lang="ts">
const items = [
{ title: "旧版更新能力", body: "旧客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
{ title: "旧版媒体源能力", body: "媒体源结构保持旧字段兼容,后台保存后会同步到旧客户端可读结构。" },
{ title: "新版动态配置", body: "新版客户端优先从服务端读取发布、接口源、健康状态和缓存策略,失败时回退旧路径。" },
{ title: "反馈兼容", body: "反馈提交和状态查询入口继续保留,后台统一沉淀为反馈工单。" },
{ title: "旧版媒体源能力", body: "媒体源目录继续保留旧字段结构,客户端无需修改即可读取。" },
{ title: "新版动态配置", body: "新版客户端优先读取发布、接口源、健康状态和缓存策略,失败时回退旧路径。" },
{ title: "反馈兼容", body: "反馈提交和状态查询入口继续保留,查询结果只展示公开进度。" },
];
</script>
@@ -10,7 +10,7 @@ const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=
<section class="page-heading">
<p class="eyebrow">Feedback</p>
<h1>反馈查询</h1>
<p>旧客户端继续向根路径提交反馈已有反馈可通过反馈码查询处理状态</p>
<p>已有反馈可通过反馈码查询公开处理状态</p>
</section>
<section class="panel feedback-panel">
@@ -19,6 +19,6 @@ const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=
<input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
</div>
<p class="muted">反馈提交接口保持旧版兼容客户端仍可 POST 到服务根路径</p>
<p class="muted">状态查询只返回公开进度公开回复和接收时间不展示后台内部处理记录</p>
</section>
</template>
@@ -22,6 +22,7 @@ const state = usePortalState();
<span>接口健康检测</span>
<span>反馈状态追踪</span>
</div>
<p v-if="state.error.value && !state.hasPartialData.value" class="empty strong">暂时无法读取公开客户端接口请稍后刷新</p>
</div>
<aside class="release-card">
@@ -58,7 +59,8 @@ const state = usePortalState();
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
</div>
<p v-else class="empty">暂无远程版本日志可在后台发布与日志中导入 update-notice</p>
<p v-else-if="state.loading.value" class="empty">正在读取版本日志...</p>
<p v-else class="empty">暂无可展示的版本日志</p>
</article>
</section>
</template>
@@ -9,7 +9,7 @@ const state = usePortalState();
<section class="page-heading">
<p class="eyebrow">Releases</p>
<h1>发布版本</h1>
<p>展示发布包下载入口和 update-notice 版本日志</p>
<p>展示客户端可见的发布包下载入口和版本日志</p>
</section>
<section class="content-grid">
@@ -25,9 +25,11 @@ const state = usePortalState();
<td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
<td><a :href="pkg.url || state.downloadUrl.value">下载</a></td>
</tr>
<tr v-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包旧客户端接口仍保持可用</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">
@@ -41,7 +43,8 @@ const state = usePortalState();
<span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span>
</div>
</section>
<p v-if="state.notices.value.length === 0" class="empty">暂无版本日志</p>
<p v-if="state.loading.value" class="empty">正在读取版本日志...</p>
<p v-else-if="state.notices.value.length === 0" class="empty">暂无版本日志</p>
</div>
</article>
</section>
@@ -9,7 +9,7 @@ const state = usePortalState();
<section class="page-heading">
<p class="eyebrow">Sources</p>
<h1>接口源健康</h1>
<p>媒体源数据源和客户端动态接口目录的可用性汇总</p>
<p>客户端可见接口目录和最近健康状态汇总</p>
</section>
<section class="panel wide">
@@ -27,6 +27,8 @@ const state = usePortalState();
</div>
</section>
</div>
<p v-else class="empty">暂无接口源数据后台同步旧媒体源配置或手动添加后会显示在这里</p>
<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>
</template>
@@ -6,12 +6,39 @@ const sources = ref<any>(null);
const notices = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const loadedAt = ref("");
const requestState = ref<Record<string, "idle" | "loading" | "ready" | "error">>({
bootstrap: "idle",
releases: "idle",
sources: "idle",
notices: "idle",
});
let loaded = false;
const endpointLabels: Record<string, string> = {
"/api/client/bootstrap": "客户端启动配置",
"/api/client/releases": "发布信息",
"/api/client/sources": "接口源目录",
"/api/client/notices": "版本日志",
};
async function fetchJSON(path: string) {
const res = await fetch(path);
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}`);
return res.json();
let res: Response;
try {
res = await fetch(path, { headers: { Accept: "application/json" } });
} catch {
throw new Error(`${endpointLabels[path] || path} 暂时无法连接`);
}
if (!res.ok) throw new Error(`${endpointLabels[path] || path} 返回 HTTP ${res.status}`);
try {
return await res.json();
} catch {
throw new Error(`${endpointLabels[path] || path} 返回内容不是有效 JSON`);
}
}
function failureMessage(reason: unknown) {
return reason instanceof Error ? reason.message : String(reason || "读取失败");
}
export function usePortalState() {
@@ -26,11 +53,16 @@ export function usePortalState() {
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 isReady = computed(() => loaded && !loading.value && !error.value);
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
const sourcesEmpty = computed(() => !loading.value && categories.value.length === 0);
async function load(force = false) {
if (loaded && !force) return;
loading.value = true;
error.value = "";
requestState.value = { bootstrap: "loading", releases: "loading", sources: "loading", notices: "loading" };
try {
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
fetchJSON("/api/client/bootstrap"),
@@ -38,15 +70,36 @@ export function usePortalState() {
fetchJSON("/api/client/sources"),
fetchJSON("/api/client/notices"),
]);
if (bootstrapData.status === "fulfilled") bootstrap.value = bootstrapData.value;
if (releaseData.status === "fulfilled") releases.value = releaseData.value;
if (sourceData.status === "fulfilled") sources.value = sourceData.value;
if (noticeData.status === "fulfilled") notices.value = noticeData.value.items || [];
if (bootstrapData.status === "fulfilled") {
bootstrap.value = bootstrapData.value;
requestState.value.bootstrap = "ready";
} else {
requestState.value.bootstrap = "error";
}
if (releaseData.status === "fulfilled") {
releases.value = releaseData.value;
requestState.value.releases = "ready";
} else {
requestState.value.releases = "error";
}
if (sourceData.status === "fulfilled") {
sources.value = sourceData.value;
requestState.value.sources = "ready";
} else {
requestState.value.sources = "error";
}
if (noticeData.status === "fulfilled") {
notices.value = noticeData.value.items || [];
requestState.value.notices = "ready";
} else {
requestState.value.notices = "error";
}
const firstFailure = [bootstrapData, releaseData, sourceData, noticeData].find((item) => item.status === "rejected") as PromiseRejectedResult | undefined;
if (firstFailure && !bootstrap.value) error.value = firstFailure.reason?.message || String(firstFailure.reason);
if (firstFailure && !hasPartialData.value) error.value = failureMessage(firstFailure.reason);
loaded = true;
loadedAt.value = new Date().toISOString();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
error.value = failureMessage(err);
} finally {
loading.value = false;
}
@@ -59,6 +112,8 @@ export function usePortalState() {
notices,
loading,
error,
loadedAt,
requestState,
packages,
categories,
latestNotice,
@@ -68,6 +123,10 @@ export function usePortalState() {
downloadUrl,
appVersion,
serviceVersion,
isReady,
hasPartialData,
releasesEmpty,
sourcesEmpty,
load,
sourceStatus,
statusTone,
@@ -82,6 +82,12 @@ button { cursor: pointer; }
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;
@@ -89,7 +95,7 @@ button { cursor: pointer; }
flex-wrap: wrap;
justify-content: center;
}
.nav-links a, .admin-link {
.nav-links a {
min-height: 38px;
display: inline-flex;
align-items: center;
@@ -107,12 +113,6 @@ button { cursor: pointer; }
background: rgba(31, 111, 91, 0.10);
transform: translateY(-1px);
}
.admin-link {
color: #fff;
background: #10231d;
box-shadow: 0 12px 28px rgba(31, 111, 91, 0.22);
}
.admin-link:hover { transform: translateY(-1px); box-shadow: 0 16px 36px rgba(31, 111, 91, 0.28); }
.hero {
position: relative;
@@ -351,6 +351,7 @@ th {
letter-spacing: 0.05em;
}
.muted, .empty { color: var(--muted); }
.empty.strong { font-weight: 900; color: var(--bad); }
.notice-list { display: grid; gap: 12px; }
.notice-card {
display: grid;
@@ -440,6 +441,7 @@ input:focus {
}
.error { color: var(--bad); }
.loading { color: var(--muted); }
.ready { color: var(--good); }
@media (max-width: 980px) {
.topnav {