feat(web): 前移 Tenants 全域資產地圖
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m32s
CD Pipeline / build-and-deploy (push) Successful in 4m47s
CD Pipeline / post-deploy-checks (push) Successful in 1m47s

This commit is contained in:
Your Name
2026-06-18 17:59:00 +08:00
parent c38d0a3d99
commit d6cdf0e66d
3 changed files with 208 additions and 2 deletions

View File

@@ -9306,7 +9306,26 @@
"gates": "閘門",
"redactedScope": "已脫敏範圍",
"blockers": "待補證據 {count}",
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}"
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}",
"ownerRequired": "需負責人",
"routePending": "待 smoke",
"sourceBlockers": "來源缺口"
},
"atlas": {
"eyebrow": "全域資產地圖",
"totalAssets": "可視資產已納入",
"totalDetail": "{products} 個產品 / 專案、{routes} 個網站 / 服務入口、{repos} 個來源範圍已進入同一張只讀台帳。",
"productMapTitle": "產品 / 專案分類",
"productMapDetail": "先看資產類型與納管狀態,再下鑽到產品列。",
"routeMapTitle": "網站 / 服務入口",
"routeMapDetail": "公開前台、後台工具、平台工具與模型服務入口合併顯示。",
"moreRoutes": "+{count} 個入口"
},
"sourceGate": {
"primaryReady": "主要來源就緒",
"ownerAccepted": "已接受回覆",
"runtimeGate": "執行閘門",
"actionButtons": "操作入口"
},
"columns": {
"domain": "網站 / 服務入口",

View File

@@ -9306,7 +9306,26 @@
"gates": "閘門",
"redactedScope": "已脫敏範圍",
"blockers": "待補證據 {count}",
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}"
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}",
"ownerRequired": "需負責人",
"routePending": "待 smoke",
"sourceBlockers": "來源缺口"
},
"atlas": {
"eyebrow": "全域資產地圖",
"totalAssets": "可視資產已納入",
"totalDetail": "{products} 個產品 / 專案、{routes} 個網站 / 服務入口、{repos} 個來源範圍已進入同一張只讀台帳。",
"productMapTitle": "產品 / 專案分類",
"productMapDetail": "先看資產類型與納管狀態,再下鑽到產品列。",
"routeMapTitle": "網站 / 服務入口",
"routeMapDetail": "公開前台、後台工具、平台工具與模型服務入口合併顯示。",
"moreRoutes": "+{count} 個入口"
},
"sourceGate": {
"primaryReady": "主要來源就緒",
"ownerAccepted": "已接受回覆",
"runtimeGate": "執行閘門",
"actionButtons": "操作入口"
},
"columns": {
"domain": "網站 / 服務入口",

View File

@@ -308,6 +308,19 @@ function assetCategoryLabel(category: string, t: ReturnType<typeof useTranslatio
return t(`categories.${key}` as never);
}
function assetStatusLabel(status: string, t: ReturnType<typeof useTranslations>) {
const key = ASSET_STATUS_KEYS[status] ?? "unknown";
return t(`statuses.${key}` as never);
}
function countBy<T>(items: T[], selectKey: (item: T) => string) {
return items.reduce<Record<string, number>>((counts, item) => {
const key = selectKey(item);
counts[key] = (counts[key] ?? 0) + 1;
return counts;
}, {});
}
function sourceRiskKey(risk: string) {
const normalized = risk.toLowerCase();
if (normalized === "high" || normalized === "medium" || normalized === "low") return normalized;
@@ -922,6 +935,39 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
const products = inventory?.products ?? [];
const publicRoutes = inventory?.public_routes ?? [];
const sourceRepos = inventory?.source_repos ?? [];
const visibleAssetCount = products.length + publicRoutes.length + sourceRepos.length;
const categoryCounts = countBy(products, (item) => item.category);
const productStatusCounts = countBy(products, (item) => item.coverage_status);
const routeStatusCounts = countBy(publicRoutes, (item) => item.coverage_status);
const routePreviewLimit = 18;
const routePreview = publicRoutes.slice(0, routePreviewLimit);
const remainingRouteCount = Math.max(publicRoutes.length - routePreview.length, 0);
const ownerRequiredProducts = products.filter(
(item) => item.coverage_status === "owner_response_required"
).length;
const pendingRouteSmokeCount = publicRoutes.filter((item) => !item.route_smoke_accepted).length;
const sourceBlockerCount = sourceRepos.reduce((total, item) => total + item.blocker_count, 0);
const productStatusEntries = Object.entries(productStatusCounts);
const routeStatusEntries = Object.entries(routeStatusCounts);
const categoryEntries = Object.entries(categoryCounts);
const sourceGateRows = [
{
label: t("sourceGate.primaryReady"),
value: `${summary?.source_primary_ready_count ?? 0}/${summary?.source_candidate_repo_count ?? sourceRepos.length}`,
},
{
label: t("sourceGate.ownerAccepted"),
value: `${summary?.owner_response_accepted_count ?? 0}`,
},
{
label: t("sourceGate.runtimeGate"),
value: `${summary?.runtime_gate_count ?? 0}`,
},
{
label: t("sourceGate.actionButtons"),
value: `${summary?.action_button_count ?? 0}`,
},
];
const metrics = [
{
key: "products",
@@ -1007,6 +1053,128 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
))}
</div>
<div className="grid gap-px border-b border-[#e0ddd4] bg-[#e0ddd4] xl:grid-cols-[0.95fr_1.05fr_1fr]">
<div className="min-w-0 bg-[#2f2a24] px-4 py-4 text-white">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#d9b36f]">
{t("atlas.eyebrow")}
</p>
<p className="mt-2 text-3xl font-semibold leading-none">{visibleAssetCount || "--"}</p>
<p className="mt-2 text-sm font-semibold">{t("atlas.totalAssets")}</p>
<p className="mt-3 text-xs leading-5 text-[#e5ddd0]">
{t("atlas.totalDetail", {
products: products.length,
routes: publicRoutes.length,
repos: sourceRepos.length,
})}
</p>
<div className="mt-4 grid grid-cols-3 gap-2 text-[11px]">
<div className="border border-[#5e5448] px-2 py-2">
<p className="text-[#c8bfb2]">{t("labels.ownerRequired")}</p>
<p className="mt-1 font-mono text-lg font-semibold text-white">{ownerRequiredProducts}</p>
</div>
<div className="border border-[#5e5448] px-2 py-2">
<p className="text-[#c8bfb2]">{t("labels.routePending")}</p>
<p className="mt-1 font-mono text-lg font-semibold text-white">{pendingRouteSmokeCount}</p>
</div>
<div className="border border-[#5e5448] px-2 py-2">
<p className="text-[#c8bfb2]">{t("labels.sourceBlockers")}</p>
<p className="mt-1 font-mono text-lg font-semibold text-white">{sourceBlockerCount}</p>
</div>
</div>
</div>
<div className="min-w-0 bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{t("atlas.productMapTitle")}</p>
<p className="mt-1 text-sm font-semibold text-[#141413]">{t("atlas.productMapDetail")}</p>
</div>
<span className="border border-[#9bc7a4] bg-[#f0faf2] px-2 py-0.5 font-mono text-xs font-semibold text-[#17602a]">
{products.length}
</span>
</div>
<div className="mt-4 grid gap-2">
{categoryEntries.map(([category, count]) => (
<div key={category} className="grid grid-cols-[128px_minmax(0,1fr)_40px] items-center gap-2 text-[11px]">
<span className="truncate font-semibold text-[#5f5b52]">
{assetCategoryLabel(category, t)}
</span>
<div className="h-2 border border-[#eee9dd] bg-[#faf9f3]">
<div
className="h-full bg-[#6f8f72]"
style={{
width: `${products.length ? Math.max(8, (count / products.length) * 100) : 0}%`,
}}
/>
</div>
<span className="text-right font-mono font-semibold text-[#141413]">{count}</span>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{productStatusEntries.map(([status, count]) => (
<span
key={status}
className="inline-flex border border-[#eee9dd] bg-[#faf9f3] px-2 py-1 text-[11px] text-[#5f5b52]"
>
<span className="font-semibold text-[#141413]">{assetStatusLabel(status, t)}</span>
<span className="ml-1 font-mono">{count}</span>
</span>
))}
</div>
</div>
<div className="min-w-0 bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{t("atlas.routeMapTitle")}</p>
<p className="mt-1 text-sm font-semibold text-[#141413]">{t("atlas.routeMapDetail")}</p>
</div>
<span className="border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 font-mono text-xs font-semibold text-[#8a5a08]">
{publicRoutes.length}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
{routePreview.map((route, index) => (
<span
key={route.domain}
className="inline-flex max-w-full border border-[#eee9dd] bg-[#faf9f3] px-2 py-1 font-mono text-[10px] text-[#141413]"
title={routePublicProductName(route, index)}
>
{route.domain}
</span>
))}
{remainingRouteCount > 0 && (
<span className="inline-flex border border-[#d8d3c7] bg-white px-2 py-1 font-mono text-[10px] font-semibold text-[#5f5b52]">
{t("atlas.moreRoutes", { count: remainingRouteCount })}
</span>
)}
</div>
<div className="mt-4 grid gap-2">
{sourceGateRows.map((row) => (
<div
key={row.label}
className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-3 border border-[#eee9dd] bg-[#faf9f3] px-2 py-1.5 text-[11px]"
>
<span className="truncate font-semibold text-[#5f5b52]">{row.label}</span>
<span className="text-right font-mono font-semibold text-[#141413]">{row.value}</span>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{routeStatusEntries.map(([status, count]) => (
<span
key={status}
className="inline-flex border border-[#eee9dd] bg-[#faf9f3] px-2 py-1 text-[11px] text-[#5f5b52]"
>
<span className="font-semibold text-[#141413]">{assetStatusLabel(status, t)}</span>
<span className="ml-1 font-mono">{count}</span>
</span>
))}
</div>
</div>
</div>
<div className="grid gap-4 p-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className="min-w-0 border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">