@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user