B 設備管理:device-store 補 registeredAt + register/unregister actions; deriveTriState 三態(已連接/未連接/已連接未註冊);三態 badge(warning token + icon + 文字不只色);排序/filter chips;註冊 UI(明確區分取消註冊≠移除)。 C 模型共享:/models/library cursor 無限捲動 + 搜尋/filter/排序;visibility badge 三態;公開設定 Dialog(RadioGroup + shares 管理 + public 警告 + 二次確認); profile 頁 owner/公開雙態;radio-group 新元件;owner 用 name 不洩 email。 共用檔 types/api.ts(B error codes)+ i18n en/zh(devices.* B / models.* C)。 零新 Design Token。reviewer B(0C/0M) + C(0C/0M、設計12/12 API8/8) 通過。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
156 lines
5.2 KiB
TypeScript
156 lines
5.2 KiB
TypeScript
/**
|
||
* Model Sharing — Mock Fixtures(平行開發用)
|
||
*
|
||
* 對 API 契約 mock(api-model-sharing.md 的 response 形狀),不依賴後端跑起來。
|
||
* 形狀為契約定義的 snake_case,經 model-sharing.ts 的 normalize 後才成前端型別。
|
||
*
|
||
* 用途:
|
||
* 1. store 在 `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1` 時走 mock(見 model-sharing-store)
|
||
* 2. 元件測試 fixture 來源
|
||
*
|
||
* ⚠️ mock owner 資料刻意不含 email(對齊契約 §4:非擁有者不該看到他人 email)。
|
||
* shares 的 email 是 owner 授權目標,才有 email。
|
||
*/
|
||
|
||
/** 契約 §1 library item 的原始(snake_case)形狀。 */
|
||
export interface RawLibraryItem {
|
||
id: string;
|
||
name: string;
|
||
description?: string;
|
||
target_chip: string;
|
||
file_size: number;
|
||
source: string;
|
||
status: string;
|
||
visibility: string;
|
||
owner: { id: string; name: string; is_me: boolean };
|
||
shared_with_me: boolean;
|
||
my_access: string;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
/** 契約 §1 分頁 response 原始形狀。 */
|
||
export interface RawLibraryPage {
|
||
items: RawLibraryItem[];
|
||
next_cursor: string | null;
|
||
has_more: boolean;
|
||
}
|
||
|
||
const NOW = "2026-08-01T00:00:00Z";
|
||
|
||
/**
|
||
* 一個較大的 mock 資料集(30 筆),用來驗證 cursor 無限捲動分頁。
|
||
* 混合三種可見性、owner/非 owner、shared_with_me 各態,覆蓋 UI 分支。
|
||
*/
|
||
export const MOCK_LIBRARY_ITEMS: RawLibraryItem[] = Array.from({ length: 30 }).map(
|
||
(_, i) => {
|
||
const isMine = i % 3 === 0;
|
||
const visibility = isMine ? "private" : i % 3 === 1 ? "public" : "tenant";
|
||
const sharedWithMe = !isMine && i % 4 === 0;
|
||
const chips = ["kl520", "kl720", "kl630", "kl730"];
|
||
const sources = ["converted", "uploaded", "preset"];
|
||
return {
|
||
id: `mock-model-${String(i + 1).padStart(2, "0")}`,
|
||
name: `mock-model-${i + 1} ${["yolov5s", "resnet50", "mobilenet", "ssd"][i % 4]}`,
|
||
description: i % 2 === 0 ? `Mock 模型 ${i + 1} 的描述` : undefined,
|
||
target_chip: chips[i % chips.length],
|
||
file_size: (i + 1) * 1024 * 1024,
|
||
source: isMine ? "converted" : sources[i % sources.length],
|
||
status: "ready",
|
||
visibility,
|
||
owner: isMine
|
||
? { id: "me", name: "我", is_me: true }
|
||
: { id: `owner-${i}`, name: `Owner ${(i % 5) + 1}`, is_me: false },
|
||
shared_with_me: sharedWithMe,
|
||
my_access: isMine ? "owner" : sharedWithMe ? "viewer" : "viewer",
|
||
created_at: `2026-07-${String((i % 28) + 1).padStart(2, "0")}T00:00:00Z`,
|
||
updated_at: NOW,
|
||
};
|
||
},
|
||
);
|
||
|
||
/**
|
||
* 對 MOCK_LIBRARY_ITEMS 套用 query(搜尋 / filter / 排序 / cursor 分頁)產生一頁。
|
||
* cursor = 已消費筆數的 base64(不透明;mock 只需能往下切)。
|
||
*/
|
||
export function mockLibraryPage(query: {
|
||
cursor?: string;
|
||
limit?: number;
|
||
q?: string;
|
||
targetChip?: string;
|
||
source?: string;
|
||
visibility?: string;
|
||
owned?: boolean;
|
||
sort?: string;
|
||
order?: string;
|
||
}): RawLibraryPage {
|
||
let items = [...MOCK_LIBRARY_ITEMS];
|
||
|
||
// filter
|
||
if (query.q) {
|
||
const q = query.q.toLowerCase();
|
||
items = items.filter(
|
||
(m) =>
|
||
m.name.toLowerCase().includes(q) ||
|
||
(m.description ?? "").toLowerCase().includes(q),
|
||
);
|
||
}
|
||
if (query.targetChip) {
|
||
items = items.filter((m) => m.target_chip === query.targetChip);
|
||
}
|
||
if (query.source) {
|
||
items = items.filter((m) => m.source === query.source);
|
||
}
|
||
if (query.visibility) {
|
||
items = items.filter((m) => m.visibility === query.visibility);
|
||
}
|
||
if (query.owned === true) {
|
||
items = items.filter((m) => m.owner.is_me);
|
||
} else if (query.owned === false) {
|
||
items = items.filter((m) => !m.owner.is_me);
|
||
}
|
||
|
||
// sort
|
||
const order = query.order === "asc" ? 1 : -1;
|
||
const sortKey = query.sort ?? "created_at";
|
||
items.sort((a, b) => {
|
||
if (sortKey === "name") return a.name.localeCompare(b.name) * order;
|
||
if (sortKey === "file_size") return (a.file_size - b.file_size) * order;
|
||
return a.created_at.localeCompare(b.created_at) * order;
|
||
});
|
||
|
||
// cursor 分頁
|
||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
||
const start = query.cursor ? Number(atob(query.cursor)) : 0;
|
||
const slice = items.slice(start, start + limit);
|
||
const nextStart = start + slice.length;
|
||
const hasMore = nextStart < items.length;
|
||
return {
|
||
items: slice,
|
||
next_cursor: hasMore ? btoa(String(nextStart)) : null,
|
||
has_more: hasMore,
|
||
};
|
||
}
|
||
|
||
/** 依 id 產生一筆 profile 原始形狀(找不到回 null,模擬 404)。 */
|
||
export function mockProfile(id: string): Record<string, unknown> | null {
|
||
const item = MOCK_LIBRARY_ITEMS.find((m) => m.id === id);
|
||
if (!item) return null;
|
||
return {
|
||
...item,
|
||
input_shape: [1, 3, 224, 224],
|
||
classes: ["person", "car", "dog", "cat"],
|
||
framework: "onnx",
|
||
can_download: item.my_access !== "none",
|
||
uploaded_at: item.created_at,
|
||
};
|
||
}
|
||
|
||
/** mock shares 清單(owner 檢視自己模型的授權對象)。 */
|
||
export const MOCK_SHARES: Record<string, Array<{ user_id: string; email: string; role: string; created_at: string }>> = {
|
||
"mock-model-01": [
|
||
{ user_id: "u-alice", email: "alice@corp.com", role: "viewer", created_at: NOW },
|
||
{ user_id: "u-bob", email: "bob@corp.com", role: "viewer", created_at: NOW },
|
||
],
|
||
};
|