feat(models): 模型庫頁按來源分三區(B2)+ 來源標籤 chart token 上色
- /models 改三區渲染:預設模型/我轉檔的/我上傳的(groupModelsBySource) - 移除 source 篩選器(三區已分來源)、保留 targetChip 篩選跨三區 - 空區顯示標題+空狀態;新 model-section 分區元件 - 來源標籤三色:preset=chart-1/converted=chart-2/uploaded=chart-3 (設計系統 token、badge + 區標題色點呼應、dark mode 自動跟隨) - i18n models.section.* + .empty.* 雙語 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
838d10b084
commit
0901ffafda
214
visionA-frontend/src/app/models/page.test.tsx
Normal file
214
visionA-frontend/src/app/models/page.test.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* /models 頁單元測試 — B2「按來源分三區」
|
||||||
|
*
|
||||||
|
* 對齊 B2(feedback 表)拍板:
|
||||||
|
* - 三區固定順序:preset → converted → uploaded
|
||||||
|
* - 依 model.source 分組
|
||||||
|
* - 移除 source 篩選、保留 targetChip 篩選且「跨三區作用」
|
||||||
|
* - 某區無 model 仍顯示區標題 + 精簡空狀態(不整區隱藏)
|
||||||
|
* - 預設區永遠有 preset model
|
||||||
|
*
|
||||||
|
* Mock:
|
||||||
|
* - model-store fetchModels → no-op(不打網路);models 用 _setModels 直接灌
|
||||||
|
* - next/navigation(ModelCard 內 Link / jsdom 無 app router context)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, within } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
import { useModelStore, type ModelSummary } from "@/stores/model-store";
|
||||||
|
|
||||||
|
import ModelsPage, { SECTION_ORDER, groupModelsBySource } from "./page";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
forward: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function model(partial: Partial<ModelSummary> & Pick<ModelSummary, "id" | "source">): ModelSummary {
|
||||||
|
return {
|
||||||
|
name: `model-${partial.id}`,
|
||||||
|
targetChip: "kl520",
|
||||||
|
fileSize: 1024,
|
||||||
|
status: "ready",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<ModelsPage />
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取得某 source 區塊的 DOM(data-source)。 */
|
||||||
|
function section(source: "preset" | "converted" | "uploaded"): HTMLElement {
|
||||||
|
const el = document.querySelector<HTMLElement>(
|
||||||
|
`[data-testid="model-section"][data-source="${source}"]`,
|
||||||
|
);
|
||||||
|
if (!el) throw new Error(`section ${source} not found`);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// mount 時 page 會呼叫 fetchModels;mock 成 no-op,避免打網路、也不覆蓋我們灌的 models。
|
||||||
|
useModelStore.setState({
|
||||||
|
models: [],
|
||||||
|
isLoading: false,
|
||||||
|
fetchModels: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
useModelStore.setState({ models: [], isLoading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("<ModelsPage /> 三區分組", () => {
|
||||||
|
it("永遠渲染三個區塊,順序固定 preset → converted → uploaded", () => {
|
||||||
|
renderPage();
|
||||||
|
const sections = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-testid="model-section"]'),
|
||||||
|
);
|
||||||
|
expect(sections).toHaveLength(3);
|
||||||
|
expect(sections.map((s) => s.getAttribute("data-source"))).toEqual([
|
||||||
|
"preset",
|
||||||
|
"converted",
|
||||||
|
"uploaded",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("依 source 把 model 分到對應區(三種 source 各分對區)", () => {
|
||||||
|
useModelStore.setState({
|
||||||
|
models: [
|
||||||
|
model({ id: "p1", source: "preset" }),
|
||||||
|
model({ id: "c1", source: "converted" }),
|
||||||
|
model({ id: "u1", source: "uploaded" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(within(section("preset")).getByText("model-p1")).toBeTruthy();
|
||||||
|
expect(within(section("preset")).queryByText("model-c1")).toBeNull();
|
||||||
|
expect(within(section("preset")).queryByText("model-u1")).toBeNull();
|
||||||
|
|
||||||
|
expect(within(section("converted")).getByText("model-c1")).toBeTruthy();
|
||||||
|
expect(within(section("converted")).queryByText("model-p1")).toBeNull();
|
||||||
|
|
||||||
|
expect(within(section("uploaded")).getByText("model-u1")).toBeTruthy();
|
||||||
|
expect(within(section("uploaded")).queryByText("model-c1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("預設區顯示所有 preset model", () => {
|
||||||
|
useModelStore.setState({
|
||||||
|
models: [
|
||||||
|
model({ id: "p1", source: "preset" }),
|
||||||
|
model({ id: "p2", source: "preset" }),
|
||||||
|
model({ id: "u1", source: "uploaded" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const presetGrid = within(section("preset")).getByTestId("model-grid");
|
||||||
|
expect(within(presetGrid).getByText("model-p1")).toBeTruthy();
|
||||||
|
expect(within(presetGrid).getByText("model-p2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("<ModelsPage /> 空區空狀態", () => {
|
||||||
|
it("某區無 model → 顯示區標題 + 精簡空狀態(不整區隱藏)", () => {
|
||||||
|
useModelStore.setState({
|
||||||
|
models: [model({ id: "p1", source: "preset" })],
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
// 三區都還在
|
||||||
|
expect(section("uploaded")).toBeTruthy();
|
||||||
|
// 上傳區無 model → 顯示空狀態,非 grid
|
||||||
|
expect(within(section("uploaded")).getByTestId("model-grid-empty")).toBeTruthy();
|
||||||
|
expect(within(section("uploaded")).queryByTestId("model-grid")).toBeNull();
|
||||||
|
// 區標題仍在
|
||||||
|
expect(within(section("uploaded")).getByText("我上傳的")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* targetChip 篩選跨三區作用 — 直接測純函式 groupModelsBySource。
|
||||||
|
*
|
||||||
|
* 不透過 Radix Select 的 UI 互動(jsdom 缺 pointer capture API,Select 開合不穩定 → flaky)。
|
||||||
|
* 純函式測試直接驗「篩選 + 分組」這個核心邏輯,deterministic。頁面把 select 的值丟給同一個
|
||||||
|
* 函式(page.tsx useMemo),所以函式對 = 跨三區作用對。
|
||||||
|
*/
|
||||||
|
describe("groupModelsBySource — targetChip 篩選跨三區作用", () => {
|
||||||
|
const data: ModelSummary[] = [
|
||||||
|
model({ id: "p520", source: "preset", targetChip: "kl520" }),
|
||||||
|
model({ id: "p720", source: "preset", targetChip: "kl720" }),
|
||||||
|
model({ id: "c720", source: "converted", targetChip: "kl720" }),
|
||||||
|
model({ id: "u520", source: "uploaded", targetChip: "kl520" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("targetChip='all' → 不篩,三組各自拿到自己 source 的全部 model", () => {
|
||||||
|
const g = groupModelsBySource(data, "all");
|
||||||
|
expect(g.preset.map((m) => m.id).sort()).toEqual(["p520", "p720"]);
|
||||||
|
expect(g.converted.map((m) => m.id)).toEqual(["c720"]);
|
||||||
|
expect(g.uploaded.map((m) => m.id)).toEqual(["u520"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targetChip='kl720' → 三區都只留 KL720;KL520-only 的 uploaded 區變空", () => {
|
||||||
|
const g = groupModelsBySource(data, "kl720");
|
||||||
|
expect(g.preset.map((m) => m.id)).toEqual(["p720"]); // p520 被濾掉
|
||||||
|
expect(g.converted.map((m) => m.id)).toEqual(["c720"]);
|
||||||
|
expect(g.uploaded).toEqual([]); // u520 是 KL520 → 套 KL720 後空
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分組永遠回三個 key(即使某 source 完全沒 model)", () => {
|
||||||
|
const g = groupModelsBySource([], "all");
|
||||||
|
expect(Object.keys(g).sort()).toEqual(["converted", "preset", "uploaded"]);
|
||||||
|
expect(g.preset).toEqual([]);
|
||||||
|
expect(g.converted).toEqual([]);
|
||||||
|
expect(g.uploaded).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SECTION_ORDER 固定為 preset → converted → uploaded", () => {
|
||||||
|
expect([...SECTION_ORDER]).toEqual(["preset", "converted", "uploaded"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 篩選後某區變空 → DOM 仍渲染該區 + 精簡空狀態(整合驗證,灌已篩好的資料模擬)。 */
|
||||||
|
describe("<ModelsPage /> 篩選後空區仍顯示空狀態", () => {
|
||||||
|
it("uploaded 區無 model(模擬篩掉後)→ 顯示區標題 + 空狀態,不整區隱藏", () => {
|
||||||
|
useModelStore.setState({
|
||||||
|
models: [
|
||||||
|
model({ id: "p720", source: "preset", targetChip: "kl720" }),
|
||||||
|
model({ id: "c720", source: "converted", targetChip: "kl720" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(section("uploaded")).toBeTruthy();
|
||||||
|
expect(within(section("uploaded")).getByTestId("model-grid-empty")).toBeTruthy();
|
||||||
|
expect(within(section("uploaded")).getByText("我上傳的")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("<ModelsPage /> loading", () => {
|
||||||
|
it("isLoading=true → 各區顯示 grid skeleton", () => {
|
||||||
|
useModelStore.setState({ isLoading: true, models: [] });
|
||||||
|
renderPage();
|
||||||
|
const skeletons = document.querySelectorAll(
|
||||||
|
'[data-testid="model-grid-skeleton"]',
|
||||||
|
);
|
||||||
|
// 三區各一個 skeleton grid
|
||||||
|
expect(skeletons).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,6 +4,11 @@
|
|||||||
* 模型庫 — /models
|
* 模型庫 — /models
|
||||||
*
|
*
|
||||||
* 對齊 pages.md §8.1、flow-model-upload.md §4.1。
|
* 對齊 pages.md §8.1、flow-model-upload.md §4.1。
|
||||||
|
*
|
||||||
|
* B2(feedback 表):頁面改成「按 model 來源分三區」——
|
||||||
|
* ① 預設模型(preset) → ② 我轉檔的(converted) → ③ 我上傳的(uploaded),順序固定。
|
||||||
|
* 原本的 source 篩選與分區重複、已移除;只留 targetChip 篩選,且它跨三區作用。
|
||||||
|
* 某區無 model(沒上傳過、或套晶片篩選後變空)仍顯示區標題 + 精簡空狀態,不整區隱藏。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@ -12,10 +17,45 @@ import {
|
|||||||
ModelFilters,
|
ModelFilters,
|
||||||
type ModelFilterValue,
|
type ModelFilterValue,
|
||||||
} from "@/components/models/model-filters";
|
} from "@/components/models/model-filters";
|
||||||
import { ModelGrid } from "@/components/models/model-grid";
|
import { ModelSection } from "@/components/models/model-section";
|
||||||
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import { useModelStore } from "@/stores/model-store";
|
import {
|
||||||
|
type ModelSource,
|
||||||
|
type ModelSummary,
|
||||||
|
type TargetChip,
|
||||||
|
useModelStore,
|
||||||
|
} from "@/stores/model-store";
|
||||||
|
|
||||||
|
/** 分區順序固定:preset → converted → uploaded(對齊 B2 拍板)。 */
|
||||||
|
export const SECTION_ORDER: readonly ModelSource[] = [
|
||||||
|
"preset",
|
||||||
|
"converted",
|
||||||
|
"uploaded",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 models 先套 targetChip 篩選(跨三區作用)、再依 source 分三組。
|
||||||
|
* 抽成純函式:分組 / 篩選邏輯可被 deterministic 單元測試,不受 Radix Select 在 jsdom 的限制。
|
||||||
|
*
|
||||||
|
* @param targetChip "all" = 不篩;否則只留 m.targetChip === targetChip 的 model。
|
||||||
|
*/
|
||||||
|
export function groupModelsBySource(
|
||||||
|
models: ModelSummary[],
|
||||||
|
targetChip: TargetChip | "all",
|
||||||
|
): Record<ModelSource, ModelSummary[]> {
|
||||||
|
const groups: Record<ModelSource, ModelSummary[]> = {
|
||||||
|
preset: [],
|
||||||
|
converted: [],
|
||||||
|
uploaded: [],
|
||||||
|
};
|
||||||
|
for (const m of models) {
|
||||||
|
if (targetChip !== "all" && m.targetChip !== targetChip) continue;
|
||||||
|
// m.source 已由 store normalize 收斂為三種之一(預設 "uploaded")。
|
||||||
|
groups[m.source].push(m);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModelsPage() {
|
export default function ModelsPage() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@ -24,21 +64,17 @@ export default function ModelsPage() {
|
|||||||
const fetchModels = useModelStore((s) => s.fetchModels);
|
const fetchModels = useModelStore((s) => s.fetchModels);
|
||||||
const [filter, setFilter] = useState<ModelFilterValue>({
|
const [filter, setFilter] = useState<ModelFilterValue>({
|
||||||
targetChip: "all",
|
targetChip: "all",
|
||||||
source: "all",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchModels();
|
void fetchModels();
|
||||||
}, [fetchModels]);
|
}, [fetchModels]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
// 先套 targetChip 篩選(跨三區作用),再依 source 分三組。
|
||||||
return models.filter((m) => {
|
const grouped = useMemo(
|
||||||
if (filter.targetChip !== "all" && m.targetChip !== filter.targetChip)
|
() => groupModelsBySource(models, filter.targetChip),
|
||||||
return false;
|
[models, filter],
|
||||||
if (filter.source !== "all" && m.source !== filter.source) return false;
|
);
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [models, filter]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
|
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
|
||||||
@ -50,7 +86,18 @@ export default function ModelsPage() {
|
|||||||
<ModelUploadDialog />
|
<ModelUploadDialog />
|
||||||
</div>
|
</div>
|
||||||
<ModelFilters value={filter} onChange={setFilter} />
|
<ModelFilters value={filter} onChange={setFilter} />
|
||||||
<ModelGrid models={filtered} loading={isLoading} />
|
<div className="space-y-8">
|
||||||
|
{SECTION_ORDER.map((source) => (
|
||||||
|
<ModelSection
|
||||||
|
key={source}
|
||||||
|
source={source}
|
||||||
|
title={t(`models.section.${source}`)}
|
||||||
|
emptyText={t(`models.section.empty.${source}`)}
|
||||||
|
models={grouped[source]}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -161,3 +161,45 @@ describe("ModelCard 下載互動", () => {
|
|||||||
expect(btn).toHaveTextContent("下載中");
|
expect(btn).toHaveTextContent("下載中");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ModelCard 來源 Badge 配色(B2 三區來源以顏色區分)", () => {
|
||||||
|
it("preset → chart-1 色", () => {
|
||||||
|
renderCard({ ...convertedReady, source: "preset" });
|
||||||
|
const badge = screen.getByTestId("model-source-badge");
|
||||||
|
expect(badge).toHaveAttribute("data-source", "preset");
|
||||||
|
expect(badge.className).toContain("text-chart-1");
|
||||||
|
expect(badge).toHaveTextContent("預設");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converted → chart-2 色", () => {
|
||||||
|
renderCard({ ...convertedReady, source: "converted" });
|
||||||
|
const badge = screen.getByTestId("model-source-badge");
|
||||||
|
expect(badge).toHaveAttribute("data-source", "converted");
|
||||||
|
expect(badge.className).toContain("text-chart-2");
|
||||||
|
expect(badge).toHaveTextContent("已轉檔");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploaded → chart-3 色(先前無 badge,現補上)", () => {
|
||||||
|
renderCard({ ...convertedReady, source: "uploaded" });
|
||||||
|
const badge = screen.getByTestId("model-source-badge");
|
||||||
|
expect(badge).toHaveAttribute("data-source", "uploaded");
|
||||||
|
expect(badge.className).toContain("text-chart-3");
|
||||||
|
expect(badge).toHaveTextContent("自行上傳");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("三種來源顏色彼此不同(chart-1/2/3)", () => {
|
||||||
|
const colorOf = (source: ModelSummary["source"]) => {
|
||||||
|
const { unmount } = renderCard({ ...convertedReady, source });
|
||||||
|
const cls = screen.getByTestId("model-source-badge").className;
|
||||||
|
const match = cls.match(/text-chart-\d/);
|
||||||
|
unmount();
|
||||||
|
return match?.[0];
|
||||||
|
};
|
||||||
|
const colors = new Set([
|
||||||
|
colorOf("preset"),
|
||||||
|
colorOf("converted"),
|
||||||
|
colorOf("uploaded"),
|
||||||
|
]);
|
||||||
|
expect(colors.size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import {
|
import {
|
||||||
isModelDownloadable,
|
isModelDownloadable,
|
||||||
useModelStore,
|
useModelStore,
|
||||||
|
type ModelSource,
|
||||||
type ModelStatus,
|
type ModelStatus,
|
||||||
type ModelSummary,
|
type ModelSummary,
|
||||||
} from "@/stores/model-store";
|
} from "@/stores/model-store";
|
||||||
@ -44,6 +45,29 @@ const STATUS_VARIANT: Record<ModelStatus, { variant: "default" | "secondary" | "
|
|||||||
rejected: { variant: "destructive", key: "models.status.rejected" },
|
rejected: { variant: "destructive", key: "models.status.rejected" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 來源 Badge 配色映射(B2:三區來源以顏色區分)。
|
||||||
|
*
|
||||||
|
* 用設計系統 chart-* token(design-review m1:不硬編碼裸 Tailwind 色階):
|
||||||
|
* preset → chart-1 / converted → chart-2 / uploaded → chart-3
|
||||||
|
* 與 model-section 區標題色點同色呼應,dark mode 由 token 自動處理。
|
||||||
|
* className 採 tint 風格(bg/10 底 + 同色文字 + /30 邊框),三色彼此區分度足夠。
|
||||||
|
*/
|
||||||
|
const SOURCE_BADGE: Record<ModelSource, { className: string; key: string }> = {
|
||||||
|
preset: {
|
||||||
|
className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
|
||||||
|
key: "models.source.preset",
|
||||||
|
},
|
||||||
|
converted: {
|
||||||
|
className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
|
||||||
|
key: "models.source.converted",
|
||||||
|
},
|
||||||
|
uploaded: {
|
||||||
|
className: "border-chart-3/30 bg-chart-3/10 text-chart-3",
|
||||||
|
key: "models.source.uploaded",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
@ -54,6 +78,7 @@ function formatFileSize(bytes: number): string {
|
|||||||
export function ModelCard({ model }: ModelCardProps) {
|
export function ModelCard({ model }: ModelCardProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const statusMeta = STATUS_VARIANT[model.status];
|
const statusMeta = STATUS_VARIANT[model.status];
|
||||||
|
const sourceMeta = SOURCE_BADGE[model.source];
|
||||||
|
|
||||||
const downloadModel = useModelStore((s) => s.downloadModel);
|
const downloadModel = useModelStore((s) => s.downloadModel);
|
||||||
// per-card loading:只在「下載中的是這張卡」時顯示 spinner。
|
// per-card loading:只在「下載中的是這張卡」時顯示 spinner。
|
||||||
@ -106,14 +131,14 @@ export function ModelCard({ model }: ModelCardProps) {
|
|||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{model.targetChip.toUpperCase()}
|
{model.targetChip.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
{model.source === "preset" && (
|
{sourceMeta && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge
|
||||||
{t("models.source.preset")}
|
variant="outline"
|
||||||
</Badge>
|
className={cn("text-xs", sourceMeta.className)}
|
||||||
)}
|
data-testid="model-source-badge"
|
||||||
{model.source === "converted" && (
|
data-source={model.source}
|
||||||
<Badge variant="secondary" className="text-xs">
|
>
|
||||||
{t("models.source.converted")}
|
{t(sourceMeta.key)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{model.category && (
|
{model.category && (
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* ModelFilters — 模型列表篩選器(雛形簡化版)
|
* ModelFilters — 模型列表篩選器(雛形簡化版)
|
||||||
*
|
*
|
||||||
* 對齊 api-spec §4 — 提供 targetChip + source 兩個常用篩選。
|
* B2 起 /models 頁改成「按來源分三區」,原本的 source 篩選與分區重複、已移除。
|
||||||
|
* 此處只保留 targetChip 篩選,且它會「跨三區作用」(選 KL520 時三區都只顯示 KL520)。
|
||||||
* Phase 1 會擴充成搜尋、分類、標籤等(design-review 缺失項:Search)。
|
* Phase 1 會擴充成搜尋、分類、標籤等(design-review 缺失項:Search)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -17,11 +18,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import type { ModelSource, TargetChip } from "@/stores/model-store";
|
import type { TargetChip } from "@/stores/model-store";
|
||||||
|
|
||||||
export interface ModelFilterValue {
|
export interface ModelFilterValue {
|
||||||
targetChip: TargetChip | "all";
|
targetChip: TargetChip | "all";
|
||||||
source: ModelSource | "all";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelFiltersProps {
|
interface ModelFiltersProps {
|
||||||
@ -60,22 +60,6 @@ export function ModelFilters({ value, onChange }: ModelFiltersProps) {
|
|||||||
<SelectItem value="kl730">KL730</SelectItem>
|
<SelectItem value="kl730">KL730</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
|
||||||
value={value.source}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
onChange({ ...value, source: v as ModelFilterValue["source"] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9 w-40" aria-label={t("models.filters.source")}>
|
|
||||||
<SelectValue placeholder={t("models.filters.source")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
|
|
||||||
<SelectItem value="uploaded">{t("models.source.uploaded")}</SelectItem>
|
|
||||||
<SelectItem value="preset">{t("models.source.preset")}</SelectItem>
|
|
||||||
<SelectItem value="converted">{t("models.source.converted")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,9 +23,19 @@ interface ModelGridProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog) */
|
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog) */
|
||||||
onUploadClick?: () => void;
|
onUploadClick?: () => void;
|
||||||
|
/**
|
||||||
|
* 區塊內空狀態文案(給「按來源分區」用)。提供時,models 為空只顯示一行精簡提示
|
||||||
|
* (配合區標題),不顯示整頁式的大型 EmptyState + CTA。未提供時維持原本大型 EmptyState。
|
||||||
|
*/
|
||||||
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
|
export function ModelGrid({
|
||||||
|
models,
|
||||||
|
loading,
|
||||||
|
onUploadClick,
|
||||||
|
emptyText,
|
||||||
|
}: ModelGridProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -42,6 +52,17 @@ export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
|
// 分區模式:只顯示精簡 inline 空狀態(區標題由外層 ModelSection 負責)。
|
||||||
|
if (emptyText !== undefined) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm"
|
||||||
|
data-testid="model-grid-empty"
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* ModelSection 測試
|
||||||
|
*
|
||||||
|
* 覆蓋(B2 三區來源以顏色區分):
|
||||||
|
* - 區標題色點顏色與來源對應(preset→chart-1 / converted→chart-2 / uploaded→chart-3),
|
||||||
|
* 與 model-card 來源 Badge 同色呼應。
|
||||||
|
* - 未知 source → fallback 到中性色(不 crash)。
|
||||||
|
*
|
||||||
|
* Mock:
|
||||||
|
* - next/navigation(ModelGrid → ModelCard 內 <Link>,jsdom 無 app router context)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
import { ModelSection } from "./model-section";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
forward: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderSection(source: string) {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<ModelSection
|
||||||
|
title={`區 ${source}`}
|
||||||
|
models={[]}
|
||||||
|
emptyText="(空)"
|
||||||
|
source={source}
|
||||||
|
/>
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ModelSection 區標題色點", () => {
|
||||||
|
it("preset → chart-1 色點", () => {
|
||||||
|
renderSection("preset");
|
||||||
|
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||||
|
"bg-chart-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converted → chart-2 色點", () => {
|
||||||
|
renderSection("converted");
|
||||||
|
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||||
|
"bg-chart-2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploaded → chart-3 色點", () => {
|
||||||
|
renderSection("uploaded");
|
||||||
|
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||||
|
"bg-chart-3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未知 source → fallback 中性色,不 crash", () => {
|
||||||
|
expect(() => renderSection("mystery")).not.toThrow();
|
||||||
|
expect(screen.getByTestId("model-section-dot").className).toContain(
|
||||||
|
"bg-border",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
visionA-frontend/src/components/models/model-section.tsx
Normal file
65
visionA-frontend/src/components/models/model-section.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelSection — 模型庫「按來源分區」的單一區塊
|
||||||
|
*
|
||||||
|
* 對齊 B2(feedback 表):/models 頁改成按 model 來源分三區
|
||||||
|
* ① 預設模型(preset) → ② 我轉檔的(converted) → ③ 我上傳的(uploaded)
|
||||||
|
*
|
||||||
|
* 每區 = 區標題 + 該 source 的 ModelGrid。即使區內無 model(如使用者沒上傳過、
|
||||||
|
* 或套用晶片篩選後變空),仍顯示區標題 + 精簡空狀態(emptyText 傳給 ModelGrid)。
|
||||||
|
* 唯一例外:loading 時整頁交給各區 ModelGrid 顯示 skeleton。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ModelGrid } from "@/components/models/model-grid";
|
||||||
|
import type { ModelSummary } from "@/stores/model-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 區標題色點配色(B2:與 model-card 來源 Badge 同色呼應)。
|
||||||
|
*
|
||||||
|
* 用設計系統 chart-* token(design-review m1:不硬編碼裸色階):
|
||||||
|
* preset → chart-1 / converted → chart-2 / uploaded → chart-3
|
||||||
|
* source 為其他值時 fallback 到中性色(border 色),確保不 crash。
|
||||||
|
*/
|
||||||
|
const SOURCE_DOT_CLASS: Record<string, string> = {
|
||||||
|
preset: "bg-chart-1",
|
||||||
|
converted: "bg-chart-2",
|
||||||
|
uploaded: "bg-chart-3",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ModelSectionProps {
|
||||||
|
/** 區標題(已翻譯文字) */
|
||||||
|
title: string;
|
||||||
|
/** 此區的 model(已依 source 分組 + 套用晶片篩選) */
|
||||||
|
models: ModelSummary[];
|
||||||
|
/** 區內無 model 時顯示的精簡空狀態文案(已翻譯) */
|
||||||
|
emptyText: string;
|
||||||
|
loading?: boolean;
|
||||||
|
/** 給測試 / DOM 定位用(如 "preset" / "converted" / "uploaded") */
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSection({
|
||||||
|
title,
|
||||||
|
models,
|
||||||
|
emptyText,
|
||||||
|
loading,
|
||||||
|
source,
|
||||||
|
}: ModelSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3" data-testid="model-section" data-source={source}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="model-section-dot"
|
||||||
|
className={`size-2.5 shrink-0 rounded-full ${
|
||||||
|
SOURCE_DOT_CLASS[source] ?? "bg-border"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<span className="text-muted-foreground text-sm">({models.length})</span>
|
||||||
|
</div>
|
||||||
|
<ModelGrid models={models} loading={loading} emptyText={emptyText} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -159,6 +159,19 @@ export const en: Dictionary = {
|
|||||||
"devices.status.error": "Error",
|
"devices.status.error": "Error",
|
||||||
"devices.status.disconnected": "Disconnected",
|
"devices.status.disconnected": "Disconnected",
|
||||||
|
|
||||||
|
// ── Devices: remove (unpair) ──
|
||||||
|
"devices.remove.action": "Remove device",
|
||||||
|
"devices.remove.removing": "Removing…",
|
||||||
|
"devices.remove.confirm.title": "Remove this device?",
|
||||||
|
"devices.remove.confirm.description":
|
||||||
|
"This unpairs “{name}” from your account and revokes its access. To use it again, you'll need to pair it from local agent. This cannot be undone.",
|
||||||
|
"devices.remove.confirm.action": "Remove",
|
||||||
|
"devices.remove.toast.success": "Device removed",
|
||||||
|
"devices.remove.error.title": "Couldn't remove device",
|
||||||
|
"devices.remove.error.FORBIDDEN": "You don't have permission to remove this device.",
|
||||||
|
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
|
||||||
|
"devices.remove.error.unknown": "Something went wrong. Please try again.",
|
||||||
|
|
||||||
// ── Remote Device Badge ──
|
// ── Remote Device Badge ──
|
||||||
"remote.status.online": "Online",
|
"remote.status.online": "Online",
|
||||||
"remote.status.offline": "Offline",
|
"remote.status.offline": "Offline",
|
||||||
@ -184,12 +197,17 @@ export const en: Dictionary = {
|
|||||||
"models.source.converted": "Converted",
|
"models.source.converted": "Converted",
|
||||||
"models.filters.label": "Model filters",
|
"models.filters.label": "Model filters",
|
||||||
"models.filters.hardware": "Hardware",
|
"models.filters.hardware": "Hardware",
|
||||||
"models.filters.source": "Source",
|
|
||||||
"models.filters.all": "All",
|
"models.filters.all": "All",
|
||||||
"models.empty.title": "No models yet",
|
"models.empty.title": "No models yet",
|
||||||
"models.empty.description":
|
"models.empty.description":
|
||||||
"Upload your first .nef model to deploy it to any paired Kneron device.",
|
"Upload your first .nef model to deploy it to any paired Kneron device.",
|
||||||
"models.empty.action": "Upload your first model",
|
"models.empty.action": "Upload your first model",
|
||||||
|
"models.section.preset": "Preset models",
|
||||||
|
"models.section.converted": "Converted by you",
|
||||||
|
"models.section.uploaded": "Uploaded by you",
|
||||||
|
"models.section.empty.preset": "No preset models available.",
|
||||||
|
"models.section.empty.converted": "You haven't converted any models yet.",
|
||||||
|
"models.section.empty.uploaded": "You haven't uploaded any models yet.",
|
||||||
"models.detail.description": "Description",
|
"models.detail.description": "Description",
|
||||||
"models.detail.version": "Version",
|
"models.detail.version": "Version",
|
||||||
"models.detail.checksum": "Checksum",
|
"models.detail.checksum": "Checksum",
|
||||||
|
|||||||
@ -160,6 +160,19 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.status.error": "錯誤",
|
"devices.status.error": "錯誤",
|
||||||
"devices.status.disconnected": "未連接",
|
"devices.status.disconnected": "未連接",
|
||||||
|
|
||||||
|
// ── Devices: 移除裝置(unpair) ──
|
||||||
|
"devices.remove.action": "移除裝置",
|
||||||
|
"devices.remove.removing": "移除中…",
|
||||||
|
"devices.remove.confirm.title": "確定要移除此裝置?",
|
||||||
|
"devices.remove.confirm.description":
|
||||||
|
"這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。",
|
||||||
|
"devices.remove.confirm.action": "移除",
|
||||||
|
"devices.remove.toast.success": "已移除裝置",
|
||||||
|
"devices.remove.error.title": "移除裝置失敗",
|
||||||
|
"devices.remove.error.FORBIDDEN": "你沒有權限移除此裝置",
|
||||||
|
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
|
||||||
|
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
|
||||||
|
|
||||||
// ── Remote Device Badge(雲端 tunnel 狀態) ──
|
// ── Remote Device Badge(雲端 tunnel 狀態) ──
|
||||||
"remote.status.online": "在線",
|
"remote.status.online": "在線",
|
||||||
"remote.status.offline": "離線",
|
"remote.status.offline": "離線",
|
||||||
@ -185,12 +198,17 @@ export const zhHant: Dictionary = {
|
|||||||
"models.source.converted": "已轉檔",
|
"models.source.converted": "已轉檔",
|
||||||
"models.filters.label": "模型篩選",
|
"models.filters.label": "模型篩選",
|
||||||
"models.filters.hardware": "硬體",
|
"models.filters.hardware": "硬體",
|
||||||
"models.filters.source": "來源",
|
|
||||||
"models.filters.all": "全部",
|
"models.filters.all": "全部",
|
||||||
"models.empty.title": "還沒有任何模型",
|
"models.empty.title": "還沒有任何模型",
|
||||||
"models.empty.description":
|
"models.empty.description":
|
||||||
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
|
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
|
||||||
"models.empty.action": "上傳第一個模型",
|
"models.empty.action": "上傳第一個模型",
|
||||||
|
"models.section.preset": "預設模型",
|
||||||
|
"models.section.converted": "我轉檔的",
|
||||||
|
"models.section.uploaded": "我上傳的",
|
||||||
|
"models.section.empty.preset": "目前沒有可用的預設模型",
|
||||||
|
"models.section.empty.converted": "你還沒有轉檔過任何模型",
|
||||||
|
"models.section.empty.uploaded": "你還沒有上傳過任何模型",
|
||||||
"models.detail.description": "說明",
|
"models.detail.description": "說明",
|
||||||
"models.detail.version": "版本",
|
"models.detail.version": "版本",
|
||||||
"models.detail.checksum": "校驗碼",
|
"models.detail.checksum": "校驗碼",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user