From 0901ffafda27e9ec1b35accfe76bd99ab9bb624c Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Wed, 1 Jul 2026 00:17:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(models):=20=E6=A8=A1=E5=9E=8B=E5=BA=AB?= =?UTF-8?q?=E9=A0=81=E6=8C=89=E4=BE=86=E6=BA=90=E5=88=86=E4=B8=89=E5=8D=80?= =?UTF-8?q?=EF=BC=88B2=EF=BC=89+=20=E4=BE=86=E6=BA=90=E6=A8=99=E7=B1=A4=20?= =?UTF-8?q?chart=20token=20=E4=B8=8A=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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) --- visionA-frontend/src/app/models/page.test.tsx | 214 ++++++++++++++++++ visionA-frontend/src/app/models/page.tsx | 71 +++++- .../src/components/models/model-card.test.tsx | 42 ++++ .../src/components/models/model-card.tsx | 41 +++- .../src/components/models/model-filters.tsx | 22 +- .../src/components/models/model-grid.tsx | 23 +- .../components/models/model-section.test.tsx | 76 +++++++ .../src/components/models/model-section.tsx | 65 ++++++ .../src/lib/i18n/dictionaries/en.ts | 20 +- .../src/lib/i18n/dictionaries/zh-Hant.ts | 20 +- 10 files changed, 552 insertions(+), 42 deletions(-) create mode 100644 visionA-frontend/src/app/models/page.test.tsx create mode 100644 visionA-frontend/src/components/models/model-section.test.tsx create mode 100644 visionA-frontend/src/components/models/model-section.tsx diff --git a/visionA-frontend/src/app/models/page.test.tsx b/visionA-frontend/src/app/models/page.test.tsx new file mode 100644 index 0000000..a0f6a65 --- /dev/null +++ b/visionA-frontend/src/app/models/page.test.tsx @@ -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 & Pick): ModelSummary { + return { + name: `model-${partial.id}`, + targetChip: "kl520", + fileSize: 1024, + status: "ready", + createdAt: "2026-01-01T00:00:00Z", + ...partial, + }; +} + +function renderPage() { + return render( + + + , + ); +} + +/** 取得某 source 區塊的 DOM(data-source)。 */ +function section(source: "preset" | "converted" | "uploaded"): HTMLElement { + const el = document.querySelector( + `[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(" 三區分組", () => { + it("永遠渲染三個區塊,順序固定 preset → converted → uploaded", () => { + renderPage(); + const sections = Array.from( + document.querySelectorAll('[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(" 空區空狀態", () => { + 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(" 篩選後空區仍顯示空狀態", () => { + 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(" 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); + }); +}); diff --git a/visionA-frontend/src/app/models/page.tsx b/visionA-frontend/src/app/models/page.tsx index 114e974..d07bf87 100644 --- a/visionA-frontend/src/app/models/page.tsx +++ b/visionA-frontend/src/app/models/page.tsx @@ -4,6 +4,11 @@ * 模型庫 — /models * * 對齊 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"; @@ -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 { + const groups: Record = { + 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({ 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 (
@@ -50,7 +86,18 @@ export default function ModelsPage() {
- +
+ {SECTION_ORDER.map((source) => ( + + ))} +
); } diff --git a/visionA-frontend/src/components/models/model-card.test.tsx b/visionA-frontend/src/components/models/model-card.test.tsx index 7e36c75..8deb48f 100644 --- a/visionA-frontend/src/components/models/model-card.test.tsx +++ b/visionA-frontend/src/components/models/model-card.test.tsx @@ -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); + }); +}); diff --git a/visionA-frontend/src/components/models/model-card.tsx b/visionA-frontend/src/components/models/model-card.tsx index 83a3f11..5d3582d 100644 --- a/visionA-frontend/src/components/models/model-card.tsx +++ b/visionA-frontend/src/components/models/model-card.tsx @@ -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 = { + 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) { {model.targetChip.toUpperCase()} - {model.source === "preset" && ( - - {t("models.source.preset")} - - )} - {model.source === "converted" && ( - - {t("models.source.converted")} + {sourceMeta && ( + + {t(sourceMeta.key)} )} {model.category && ( diff --git a/visionA-frontend/src/components/models/model-filters.tsx b/visionA-frontend/src/components/models/model-filters.tsx index eddf01a..469d491 100644 --- a/visionA-frontend/src/components/models/model-filters.tsx +++ b/visionA-frontend/src/components/models/model-filters.tsx @@ -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) { KL730 - ); } diff --git a/visionA-frontend/src/components/models/model-grid.tsx b/visionA-frontend/src/components/models/model-grid.tsx index 95ba3a8..6f82a7b 100644 --- a/visionA-frontend/src/components/models/model-grid.tsx +++ b/visionA-frontend/src/components/models/model-grid.tsx @@ -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 ( +

+ {emptyText} +

+ ); + } return ( ,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( + + + , + ); +} + +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", + ); + }); +}); diff --git a/visionA-frontend/src/components/models/model-section.tsx b/visionA-frontend/src/components/models/model-section.tsx new file mode 100644 index 0000000..f685962 --- /dev/null +++ b/visionA-frontend/src/components/models/model-section.tsx @@ -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 = { + 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 ( +
+
+
+ +
+ ); +} diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index a197a7c..f099782 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -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", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index f1f7ef4..c1e3bbd 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -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": "校驗碼",