feat(visionA-frontend): B4 model 詳細頁顯示 metadata(input_shape/classes/framework)

對應 backend B4:model 詳細頁顯示轉檔帶來的 metadata。
- Model 型別加 inputShape/classes/framework(optional);normalize 雙吃 snake/camel
- input_shape 顯示成 1 × 3 × 224 × 224;classes 前 8 個 + N 折疊;framework 直接顯示
- 有值才顯示(沒值不渲染空白);中英 i18n
- tsc / lint / next build 綠

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-06-21 06:37:36 +08:00
parent cabbdde495
commit dc1c0dbee4
4 changed files with 83 additions and 0 deletions

View File

@ -37,6 +37,14 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} }
/** input_shapeNCHW如 [1,3,224,224])→ 易讀字串 `1 × 3 × 224 × 224`。 */
function formatInputShape(shape: number[]): string {
return shape.join(" × ");
}
/** classes 多時只展示前幾個 Badge其餘以 `+N` 表示,避免塞爆版面。 */
const CLASSES_PREVIEW_LIMIT = 8;
interface ModelDetailClientProps { interface ModelDetailClientProps {
id: string; id: string;
} }
@ -156,6 +164,23 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
label={t("models.createdAt")} label={t("models.createdAt")}
value={selectedModel.createdAt ? new Date(selectedModel.createdAt).toLocaleString() : "—"} value={selectedModel.createdAt ? new Date(selectedModel.createdAt).toLocaleString() : "—"}
/> />
{/* B4 轉檔 metadata有值才顯示沒值時 backend omitemptynormalize 後為 undefined。 */}
{selectedModel.framework && (
<InfoRow
label={t("models.detail.framework")}
value={<span className="font-mono text-xs">{selectedModel.framework}</span>}
/>
)}
{selectedModel.inputShape && selectedModel.inputShape.length > 0 && (
<InfoRow
label={t("models.detail.inputShape")}
value={
<span className="font-mono text-xs">
{formatInputShape(selectedModel.inputShape)}
</span>
}
/>
)}
{selectedModel.version && ( {selectedModel.version && (
<InfoRow label={t("models.detail.version")} value={selectedModel.version} /> <InfoRow label={t("models.detail.version")} value={selectedModel.version} />
)} )}
@ -166,6 +191,30 @@ export function ModelDetailClient({ id }: ModelDetailClientProps) {
/> />
)} )}
</div> </div>
{/* classes 可能很長,獨立成 full-width 區塊,用 Badge 列出(超量只顯示前 N 個 + 餘量)。 */}
{selectedModel.classes && selectedModel.classes.length > 0 && (
<div className="space-y-2 border-t pt-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{t("models.detail.classes")}</span>
<span className="text-muted-foreground text-xs">
{selectedModel.classes.length} {t("models.detail.classesCountSuffix")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{selectedModel.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
{c}
</Badge>
))}
{selectedModel.classes.length > CLASSES_PREVIEW_LIMIT && (
<Badge variant="outline" className="font-normal">
+{selectedModel.classes.length - CLASSES_PREVIEW_LIMIT}
</Badge>
)}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -195,6 +195,10 @@ export const en: Dictionary = {
"models.detail.checksum": "Checksum", "models.detail.checksum": "Checksum",
"models.detail.supportedChips": "Supported chips", "models.detail.supportedChips": "Supported chips",
"models.detail.deployToDevice": "Deploy to device", "models.detail.deployToDevice": "Deploy to device",
"models.detail.inputShape": "Input shape",
"models.detail.framework": "Framework",
"models.detail.classes": "Classes",
"models.detail.classesCountSuffix": "classes",
// ── Model Upload Dialog ── // ── Model Upload Dialog ──
"models.upload.button": "Upload model", "models.upload.button": "Upload model",

View File

@ -196,6 +196,10 @@ export const zhHant: Dictionary = {
"models.detail.checksum": "校驗碼", "models.detail.checksum": "校驗碼",
"models.detail.supportedChips": "支援晶片", "models.detail.supportedChips": "支援晶片",
"models.detail.deployToDevice": "部署至裝置", "models.detail.deployToDevice": "部署至裝置",
"models.detail.inputShape": "輸入形狀",
"models.detail.framework": "來源框架",
"models.detail.classes": "類別",
"models.detail.classesCountSuffix": "個類別",
// ── Model Upload Dialogflow-model-upload ── // ── Model Upload Dialogflow-model-upload ──
"models.upload.button": "上傳模型", "models.upload.button": "上傳模型",

View File

@ -58,6 +58,13 @@ export interface Model extends ModelSummary {
version?: string; version?: string;
checksum?: string; checksum?: string;
downloadUrl?: string; // presigned GET downloadUrl?: string; // presigned GET
/**
* metadataB4 backend result omitempty JSON
* optional
*/
inputShape?: number[]; // NCHW如 [1, 3, 224, 224]
classes?: string[]; // 類別清單
framework?: string; // 來源框架,如 onnx / tflite
} }
/** `POST /api/models/init` 的回傳(對齊 api-spec §4 */ /** `POST /api/models/init` 的回傳(對齊 api-spec §4 */
@ -94,6 +101,22 @@ function normalizeModelSummary(raw: unknown): ModelSummary {
}; };
} }
/** 把後端帶來的 metadata 陣列正規化:只接受陣列、過濾掉 null/undefined空陣列回 undefined。 */
function normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v));
return arr.length > 0 ? arr : undefined;
}
/** input_shapenumber[],過濾非有限數值;空陣列回 undefined讓 UI「有值才顯示」。 */
function normalizeNumberArray(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value.map((v) => Number(v)).filter((n) => Number.isFinite(n));
return arr.length > 0 ? arr : undefined;
}
function normalizeModel(raw: unknown): Model { function normalizeModel(raw: unknown): Model {
const summary = normalizeModelSummary(raw); const summary = normalizeModelSummary(raw);
const r = (raw ?? {}) as Record<string, unknown>; const r = (raw ?? {}) as Record<string, unknown>;
@ -106,6 +129,9 @@ function normalizeModel(raw: unknown): Model {
(r.download_url as string | undefined) ?? (r.download_url as string | undefined) ??
(r.downloadUrl as string | undefined) ?? (r.downloadUrl as string | undefined) ??
undefined, undefined,
inputShape: normalizeNumberArray(r.input_shape ?? r.inputShape),
classes: normalizeStringArray(r.classes),
framework: (r.framework as string | undefined) ?? undefined,
}; };
} }