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:
jim800121chen 2026-07-01 00:17:19 +08:00
parent 838d10b084
commit 0901ffafda
10 changed files with 552 additions and 42 deletions

View File

@ -0,0 +1,214 @@
/**
* /models B2
*
* B2feedback
* - preset converted uploaded
* - model.source
* - source targetChip
* - model +
* - preset model
*
* Mock
* - model-store fetchModels no-opmodels _setModels
* - next/navigationModelCard 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 區塊的 DOMdata-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 會呼叫 fetchModelsmock 成 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 APISelect 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' → 三區都只留 KL720KL520-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);
});
});

View File

@ -4,6 +4,11 @@
* /models
*
* pages.md §8.1flow-model-upload.md §4.1
*
* B2feedback model
* (preset) (converted) (uploaded)
* source targetChip
* model +
*/
import { useEffect, useMemo, useState } from "react";
@ -12,10 +17,45 @@ import {
ModelFilters,
type ModelFilterValue,
} 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 { 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() {
const t = useT();
@ -24,21 +64,17 @@ export default function ModelsPage() {
const fetchModels = useModelStore((s) => s.fetchModels);
const [filter, setFilter] = useState<ModelFilterValue>({
targetChip: "all",
source: "all",
});
useEffect(() => {
void fetchModels();
}, [fetchModels]);
const filtered = useMemo(() => {
return models.filter((m) => {
if (filter.targetChip !== "all" && m.targetChip !== filter.targetChip)
return false;
if (filter.source !== "all" && m.source !== filter.source) return false;
return true;
});
}, [models, filter]);
// 先套 targetChip 篩選(跨三區作用),再依 source 分三組。
const grouped = useMemo(
() => groupModelsBySource(models, filter.targetChip),
[models, filter],
);
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
@ -50,7 +86,18 @@ export default function ModelsPage() {
<ModelUploadDialog />
</div>
<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>
);
}

View File

@ -161,3 +161,45 @@ describe("ModelCard 下載互動", () => {
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);
});
});

View File

@ -28,6 +28,7 @@ import { cn } from "@/lib/utils";
import {
isModelDownloadable,
useModelStore,
type ModelSource,
type ModelStatus,
type ModelSummary,
} from "@/stores/model-store";
@ -44,6 +45,29 @@ const STATUS_VARIANT: Record<ModelStatus, { variant: "default" | "secondary" | "
rejected: { variant: "destructive", key: "models.status.rejected" },
};
/**
* Badge B2
*
* chart-* tokendesign-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 {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@ -54,6 +78,7 @@ function formatFileSize(bytes: number): string {
export function ModelCard({ model }: ModelCardProps) {
const t = useT();
const statusMeta = STATUS_VARIANT[model.status];
const sourceMeta = SOURCE_BADGE[model.source];
const downloadModel = useModelStore((s) => s.downloadModel);
// per-card loading只在「下載中的是這張卡」時顯示 spinner。
@ -106,14 +131,14 @@ export function ModelCard({ model }: ModelCardProps) {
<Badge variant="outline" className="text-xs">
{model.targetChip.toUpperCase()}
</Badge>
{model.source === "preset" && (
<Badge variant="secondary" className="text-xs">
{t("models.source.preset")}
</Badge>
)}
{model.source === "converted" && (
<Badge variant="secondary" className="text-xs">
{t("models.source.converted")}
{sourceMeta && (
<Badge
variant="outline"
className={cn("text-xs", sourceMeta.className)}
data-testid="model-source-badge"
data-source={model.source}
>
{t(sourceMeta.key)}
</Badge>
)}
{model.category && (

View File

@ -3,7 +3,8 @@
/**
* ModelFilters
*
* api-spec §4 targetChip + source
* B2 /models source
* targetChip KL520 KL520
* Phase 1 design-review Search
*/
@ -17,11 +18,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useT } from "@/lib/i18n/context";
import type { ModelSource, TargetChip } from "@/stores/model-store";
import type { TargetChip } from "@/stores/model-store";
export interface ModelFilterValue {
targetChip: TargetChip | "all";
source: ModelSource | "all";
}
interface ModelFiltersProps {
@ -60,22 +60,6 @@ export function ModelFilters({ value, onChange }: ModelFiltersProps) {
<SelectItem value="kl730">KL730</SelectItem>
</SelectContent>
</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>
);
}

View File

@ -23,9 +23,19 @@ interface ModelGridProps {
loading?: boolean;
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog */
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();
if (loading) {
@ -42,6 +52,17 @@ export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
}
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 (
<EmptyState
icon={Boxes}

View File

@ -0,0 +1,76 @@
/**
* ModelSection
*
* B2
* - presetchart-1 / convertedchart-2 / uploadedchart-3
* model-card Badge
* - source fallback crash
*
* Mock
* - next/navigationModelGrid 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",
);
});
});

View File

@ -0,0 +1,65 @@
"use client";
/**
* ModelSection
*
* B2feedback /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-* tokendesign-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>
);
}

View File

@ -159,6 +159,19 @@ export const en: Dictionary = {
"devices.status.error": "Error",
"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.status.online": "Online",
"remote.status.offline": "Offline",
@ -184,12 +197,17 @@ export const en: Dictionary = {
"models.source.converted": "Converted",
"models.filters.label": "Model filters",
"models.filters.hardware": "Hardware",
"models.filters.source": "Source",
"models.filters.all": "All",
"models.empty.title": "No models yet",
"models.empty.description":
"Upload your first .nef model to deploy it to any paired Kneron device.",
"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.version": "Version",
"models.detail.checksum": "Checksum",

View File

@ -160,6 +160,19 @@ export const zhHant: Dictionary = {
"devices.status.error": "錯誤",
"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.status.online": "在線",
"remote.status.offline": "離線",
@ -185,12 +198,17 @@ export const zhHant: Dictionary = {
"models.source.converted": "已轉檔",
"models.filters.label": "模型篩選",
"models.filters.hardware": "硬體",
"models.filters.source": "來源",
"models.filters.all": "全部",
"models.empty.title": "還沒有任何模型",
"models.empty.description":
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
"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.version": "版本",
"models.detail.checksum": "校驗碼",