feat(visionA-frontend): Phase 0 → 0.7 雲端前端(Next.js + OIDC redirect 流程)

visionA 雲端版前端 — 沿用 local-tool 既有 UI(原則 4:先抄 local-tool)+
新增雲端特有的登入 / 配對 / 設定流程,含以下整合階段:

- Phase 0:13 頁 + 30+ 元件 + 雛形 banner
  - dashboard / devices / models / workspace / clusters / settings 等頁
  - AppShell + Sidebar + Header + tokens + i18n(中英雙語 96 keys)
  - API client + 5 stores + 3 hooks
- Phase 0.6:OIDC redirect 改造
  - login 頁改為 OIDC redirect(`window.location.href = /api/auth/login`)
  - register 改說明頁、account 改唯讀(user 資料來源是 MC)
  - api client 改 cookie session(credentials: include)+ 完全清掉 localStorage
- Phase 0.7:stage 部署 + nil guard
  - getApiBaseUrl() 修:browser 環境視為 same-origin(與 login 頁一致)
  - login 頁加「已登入 → router.replace('/')」effect
  - User type email/name 改 optional(MC id_token 不一定回 email/name claim)
  - header.tsx UserMenu displayName 4 層 fallback:name → email → id → i18n
  - 雛形 banner 文案更新(已接 Innovedus 帳號中心)+ 版號 Phase 0.7

驗證:pnpm lint / test (125/125) / build 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-01 11:21:36 +08:00
parent 22f0837ba8
commit 99dea42239
115 changed files with 19261 additions and 0 deletions

View File

@ -0,0 +1,38 @@
# visionA Cloud Frontend — 環境變數範本
#
# 複製一份為 `.env.local`,並依實際環境填入。
# `.env.local` 由 .gitignore 忽略,**切勿 commit**。
#
# 所有 runtime 變數都以 `NEXT_PUBLIC_` 開頭Next.js 要求前端可讀)。
# ⚠️ 因此不要放真正機密的值——這類值應走後端 API。
# 後端 API server base URL不含 trailing slash
# 預設對應 visionA-backend 本地開發 portTDD §1.4 api-server 預設 3001
# F5 雛形階段允許以 3721 對應 local-tool 既有後端,以方便整合測試)
#
# ⚠️ BFF PatternOF2 / Phase 0.6 OIDC 之後):
# - 所有 API 請求帶 `credentials: 'include'`,瀏覽器會自動攜帶 backend 設的
# `visiona_session` HttpOnly cookie。
# - 跨 origin本範例frontend localhost:3000 ↔ backend localhost:3721
# backend CORS 必須回傳:
# * `Access-Control-Allow-Credentials: true`
# * `Access-Control-Allow-Origin: http://localhost:3000`(明確 origin**禁止 `*`**
# - 上述條件任一不符 → 瀏覽器會在 console 報 CORS 錯誤、fetch 拋 NetworkError。
# - 同 origin 部署prod 通常將 frontend / backend 放同網域)時無此限制。
NEXT_PUBLIC_API_BASE=http://localhost:3721
# WebSocket base URL通常是 API base 改 ws/wss同源部署時可留空讓前端自動推導
NEXT_PUBLIC_WS_BASE=ws://localhost:3721
# Innovedus Member Center 註冊頁 URLPhase 0.6 OIDC 接入;對齊 oidc-tdd.md §10.1 / §10.5
# - login 頁的「前往註冊」連結會指向這裡(在新分頁開啟)
# - register 頁的「前往 Innovedus 帳號中心」按鈕也會跳到這裡(同分頁完整導航)
# - 未設定(或留空)→ 兩處 UI 均會 disablearia-disabled="true"
# - dev 預設指向 Member Center 本地 dev port 5050
NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL=http://localhost:5050/account/register
# Innovedus Member Center profile 頁 URLPhase 0.6 OIDC 接入;對齊 oidc-tdd.md §10.3
# - /account 頁「前往 Innovedus 帳號中心」按鈕會指向這裡(在新分頁開啟)
# - 未設定(或留空)→ 按鈕會被替換為「未設定」hint
# - dev 預設指向 Member Center 本地 dev port 5050
NEXT_PUBLIC_MEMBER_CENTER_PROFILE_URL=http://localhost:5050/account/profile

44
visionA-frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
/dist/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files可依需要 opt-in 提交,例如 .env.example / .env.local.example
.env*
!.env.example
!.env.local.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

234
visionA-frontend/README.md Normal file
View File

@ -0,0 +1,234 @@
# visionA Cloud Frontend
> visionA Cloud 前端 Web 應用Next.js 16 + React 19 + Tailwind 4
>
> 目的:將 `local-tool/` 的使用體驗延伸到雲端,讓使用者透過瀏覽器操作由 local agent 連入的邊緣裝置Kneron KL520/KL720 等)。
> 🚧 **Phase 0 雛形** — 任何功能皆以 UI 走通為目標不做真實身分驗證、OAuth、WebSocket 即時推送、MJPEG 串流。完整功能清單見下方「雛形範圍與限制」。
>
> 規劃中的正式版請見 [`.autoflow/02-prd/PRD.md`](../.autoflow/02-prd/PRD.md),架構決策見 [`.autoflow/04-architecture/design-doc.md`](../.autoflow/04-architecture/design-doc.md)。
---
## 技術堆疊
| 層級 | 技術 | 版本 |
|------|------|------|
| 框架 | Next.jsApp Router | 16.1.6 |
| UI 函式庫 | React / React DOM | 19.2.3 |
| 語言 | TypeScript | ^5 |
| 樣式 | Tailwind CSS`@tailwindcss/postcss`| ^4 |
| 元件 primitive | Radix UI | ^1.4.3 |
| 元件規範 | shadcn `new-york` style`components.json` | — |
| 圖示 | lucide-react | ^0.575.0 |
| 狀態管理 | Zustand | ^5.0.11 |
| 主題 | next-themes | ^0.4.6 |
| Toast | sonner | ^2.0.7 |
| Class 工具 | clsx + tailwind-merge + class-variance-authority | — |
| 測試 | Vitest + @testing-library/react + jsdom | ^4 / ^16 / ^28 |
| Lint | ESLint + eslint-config-next | ^9 / 16.1.6 |
版本與 [`local-tool/frontend/`](../local-tool/frontend/) 對齊,確保「雲端版 / 離線版同一套前端」的架構決策可執行。
---
## 前置需求
- Node.js **≥ 20**
- pnpm **≥ 10**
- **依賴:** 需同時運行 [`visionA-backend`](../visionA-backend/README.md)Go。前端所有 API 呼叫會指向 `NEXT_PUBLIC_API_BASE`,後端未啟動時功能會顯示錯誤但 UI 仍可瀏覽(多數頁面對 501 / 連線錯誤已做 graceful fallback
---
## 快速啟動
```bash
# 1. 安裝依賴
cd visionA-frontend
pnpm install
# 2. 設定環境變數
cp .env.local.example .env.local
# 檢查 NEXT_PUBLIC_API_BASE 是否指到正確後端位址
# 3. 啟動後端(另一個 terminal
cd ../visionA-backend
make run # 或參考 visionA-backend/README.md
# 4. 啟動前端
cd ../visionA-frontend
pnpm dev # http://localhost:3000
```
進入後**任何 email + 任何密碼**都可登入Phase 0 的 StaticAuthProvider
### 可用腳本
| 指令 | 說明 |
|------|------|
| `pnpm dev` | 開發模式Turbopack / HMR |
| `pnpm build` | 產線打包(`output: "standalone"`,便於 Docker 部署) |
| `pnpm start` | 啟動 production build |
| `pnpm lint` | 執行 ESLint |
| `pnpm test` | 執行 Vitest一次性 |
| `pnpm test:watch` | 執行 Vitestwatch mode |
---
## 專案結構
```
visionA-frontend/
├── src/
│ ├── app/ # Next.js App Router 路由
│ │ ├── layout.tsx # Root layoutTheme / Locale / AppShell
│ │ ├── page.tsx # / Dashboard
│ │ ├── login/ # /login
│ │ ├── register/ # /registerPhase 0 Coming Soon
│ │ ├── account/ # /account 帳號設定 stub
│ │ ├── devices/ # /devices, /devices/[id], /devices/pair
│ │ ├── models/ # /models, /models/[id]
│ │ ├── workspace/ # /workspace, /workspace/[deviceId]
│ │ ├── clusters/ # /clustersPhase 0 預告頁)
│ │ └── settings/ # /settings
│ ├── components/
│ │ ├── ui/ # Shadcn primitiveButton / Card / Input ...
│ │ ├── layout/ # Sidebar / Header / PrototypeBanner / AppShell
│ │ ├── dashboard/ # StatCard / ActivityTimeline / ConnectedDevicesList
│ │ ├── devices/ # DeviceCard 相關
│ │ ├── models/ # ModelCard / ModelUploadDialog
│ │ ├── cloud/ # RemoteDeviceBadge
│ │ └── pairing/ # PairingTokenCard / PairingCountdown
│ ├── hooks/ # useFetch / useWebsocket / useTunnelStatus
│ ├── lib/
│ │ ├── api.ts # 統一 API clientenvelope / 401 / 501 / timeout
│ │ ├── utils.ts # cn() 等
│ │ └── i18n/ # zh-Hant / en 字典 + Context
│ ├── stores/ # Zustand storesauth / session / device / model / activity / pairing
│ ├── tests/ # Vitest setup
│ └── types/ # 共用型別api / user / pairing ...
├── public/ # 靜態資源
├── components.json # Shadcn CLI 設定
├── next.config.ts # output: standalone
├── tsconfig.json # @/* → ./src/*
└── package.json
```
---
## 主要頁面清單
| 路徑 | Phase 0 狀態 | 說明 |
|------|-------------|------|
| `/login` | ✅ 可用 | 任意帳密皆通過StaticAuth |
| `/register` | ⚠️ Coming Soon | 引導回 /loginPhase 1 才實作 |
| `/` Dashboard | ✅ 可用 | StatCard × 4、近期活動、快速操作無裝置時顯示空狀態引導 /devices/pair |
| `/devices` | ✅ 可用 | 裝置列表RemoteDeviceBadge 顯示遠端連線狀態 |
| `/devices/[id]` | ✅ 可用 | 裝置詳細、離線 banner |
| `/devices/pair` | ✅ 可用(核心) | 三步配對流程:產生 Pairing Token → 15 分鐘倒數 → 輪詢 3 分鐘 |
| `/models` | ✅ 可用 | 模型列表 + 上傳 DialogXHR 進度) |
| `/models/[id]` | ✅ 可用 | 模型詳細、部署到裝置 |
| `/workspace` | ✅ 可用 | 選擇線上裝置 |
| `/workspace/[deviceId]` | ⚠️ Camera placeholder | 推論 Start/Stop 可按MJPEG stream Phase 1 接上 |
| `/clusters` | ⚠️ Phase 1 預告 | POC 有完整實作,雛形後端是 stub |
| `/account` | ✅ 可用 stub | 顯示使用者 + 登出;其他操作是 toast 提醒 |
| `/settings` | ✅ 可用 | 語言 / 主題 / API 端點切換 |
---
## 環境變數
| 變數 | 說明 | 預設值 | 必要 |
|------|------|--------|------|
| `NEXT_PUBLIC_API_BASE` | 雲端 API Server 位址(無尾斜線) | `http://localhost:3721` | 是 |
| `NEXT_PUBLIC_WS_BASE` | WebSocket 位址;留空則從 API 推導 | 從 API base 推導 | 否 |
所有 runtime 變數都以 `NEXT_PUBLIC_` 開頭Next.js 要求前端可讀),**不要放真正機密的值**——這類值應走後端。
---
## Phase 0 雛形範圍與限制
### ✅ 可做到的
- 完整 UI 骨架Sidebar / Header / 全域雛形 Banner / Sonner toast
- i18nzh-Hant / en可即時切換key 集合兩語系強制同步
- Light / Dark / System 主題切換
- 裝置 / 模型 / 活動 三個 store 的 CRUD UI對齊 api-spec
- **Pairing 流程**:產 token → 視覺切兩行顯示(複製永遠是完整 36 字元)→ 15 分鐘倒數3 色階段)→ 輪詢連線狀態 → 成功 toast + 跳轉
- 離線降級:`RemoteDeviceBadge` + 頁面離線 Banner + Workspace 遮罩
- 模型上傳XHR progress + presigned URL 直送 storage
### ❌ Phase 0 刻意不做Phase 1 接手)
- **任何帳密可登入** — 後端 `StaticAuthProvider``demo-user`;重啟後端後前端 localStorage 的 token 雖然還在,但伺服器上沒有任何 session 資料
- **OAuth / 2FA / 密碼重設** — 全部 Phase 1
- **註冊功能** — 顯示 Coming Soon 頁面
- **重啟後端 → 裝置 / 模型資料消失** — 後端目前是 InMemoryRepository
- **WebSocket 即時推送** — 事件 / 推論結果 / 配對狀態的 WS endpoint 目前回 501前端改用 3 秒輪詢pairing 頁)
- **Camera / MJPEG 串流**`/workspace/[deviceId]` 的 Camera tab 顯示 placeholderImage / Video / Batch tabs disabled
- **Cluster CRUD**`/clusters` 顯示 Phase 1 預告頁
- **Account CRUD** — 變更個人資料 / 刪除帳號都是 toast stub
- **真實 SHA-256 checksum** — 模型上傳用 `placeholder:{size}:{nameLen}`(詳見 `model-upload-dialog.tsx` §placeholderChecksum
前端安全債詳見 [`.autoflow/04-architecture/security.md`](../.autoflow/04-architecture/security.md) §14localStorage token / WS querystring / CSP / CSRF 等 6 項,每項有 Phase 1 計畫)。
---
## 效能預算Phase 1 開始嚴格執行)
| 資源 | 預算 |
|------|------|
| JavaScript首次載入壓縮後 | < 200 KB |
| CSS壓縮後 | < 50 KB |
| 首次載入總大小 | < 500 KB |
| Core Web Vitals | LCP < 2.5sINP < 200msCLS < 0.1 |
雛形Phase 0階段僅確保 `pnpm build` 成功CI 中加入 bundle 檢查由 DevOps 任務處理。
---
## 無障礙規範
目標 **WCAG 2.2 AA**(詳見 [`.autoflow/03-design/design-spec.md`](../.autoflow/03-design/design-spec.md) §8
- 色彩對比:一般文字 ≥ 4.5:1、大文字 ≥ 3:1
- 鍵盤可操作所有核心流程Tab / Enter / Escape
- Focus ring 可見shadcn 預設 `ring-2 ring-ring`
- 語意化 HTML`<nav>``<main>``<aside>``<header>`
- 動態內容 `aria-live`
- Pairing token 顯示:`role="text"` + 完整 token 於 `aria-label`(避免 SR 一字一念)
- Status card成功 / 失敗用 `role="status"` / `role="alert"` + `aria-live`
---
## 測試
- **策略**Unit ~60% / Integration ~25% / Visual ~10% / E2E ~5%E2E 交給 Testing Agent
- **工具**Vitest + React Testing Library + jsdom
- **當前**15 個測試檔、94 個 test 全綠
- **強制一致**`i18n.test.ts` 驗 zh-Hant / en 兩份字典 key 集合完全一致且無空字串
```bash
pnpm test # 一次跑完
pnpm test:watch # watch mode
```
---
## 部署
- `next.config.ts` 已設 `output: "standalone"`,方便未來 Docker 化
- Dockerfile / docker-compose / Helm chart 由 DevOps Agent 規劃Phase 1
---
## 相關文件
- PRD[`.autoflow/02-prd/PRD.md`](../.autoflow/02-prd/PRD.md)
- 設計規格索引:[`.autoflow/03-design/design-spec.md`](../.autoflow/03-design/design-spec.md)
- Design Tokens[`.autoflow/03-design/design-tokens.md`](../.autoflow/03-design/design-tokens.md)
- Pairing 流程:[`.autoflow/03-design/flows/flow-pairing.md`](../.autoflow/03-design/flows/flow-pairing.md)
- Auth 流程:[`.autoflow/03-design/flows/flow-auth.md`](../.autoflow/03-design/flows/flow-auth.md)
- TDD §10 前端章節:[`.autoflow/04-architecture/TDD.md`](../.autoflow/04-architecture/TDD.md)
- API Spec[`.autoflow/04-architecture/api/api-spec.md`](../.autoflow/04-architecture/api/api-spec.md)
- 前端安全債:[`.autoflow/04-architecture/security.md`](../.autoflow/04-architecture/security.md) §14
- 整體進度:[`.autoflow/progress.md`](../.autoflow/progress.md)

View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// 覆寫 eslint-config-next 預設 ignore加入 .next 各子目錄與測試產物
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"coverage/**",
]),
]);
export default eslintConfig;

View File

@ -0,0 +1,15 @@
import type { NextConfig } from "next";
/**
* visionA Cloud Next.js Phase 0
*
* - output: "standalone" server 便 Docker
* - local-tool rewrites API base URL NEXT_PUBLIC_API_BASE
* F5 src/lib/api.ts runtime
*/
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
};
export default nextConfig;

View File

@ -0,0 +1,43 @@
{
"name": "visiona-frontend",
"version": "0.1.0",
"private": true,
"description": "visionA Cloud 前端Phase 0 雛形骨架)",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
}
}

6978
visionA-frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1 @@
此目錄存放靜態資源favicon、logo 等)。

View File

@ -0,0 +1,262 @@
/**
* /account Phase 0.6 OIDC + Member Center
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.3
* - OF3visionA-frontend account
*
*
* 1. user.idOIDC sub/ email / name readOnly
* 2. Innovedus href +
* fallback hint
* 3. authStore.logout() router.push('/login')
* 4. disabled tooltip
* 5. displayName createdAt placeholder
*
*
* - AuthGate layout redirect
* user=null fallback render UI
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { useAuthStore } from "@/stores/auth-store";
import AccountPage from "./page";
/* -------------------------------------------------------------------------- */
/* mock next/navigation 的 useRouter */
/* -------------------------------------------------------------------------- */
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
}),
}));
/* -------------------------------------------------------------------------- */
/* helpers */
/* -------------------------------------------------------------------------- */
function renderAccount() {
return render(
<LocaleProvider>
<AccountPage />
</LocaleProvider>,
);
}
function setUser(user: { id: string; email: string; name: string } | null) {
useAuthStore.setState({ user });
}
function resetStore() {
useAuthStore.setState({ user: null, isLoading: false, error: null });
}
/* -------------------------------------------------------------------------- */
/* tests */
/* -------------------------------------------------------------------------- */
describe("<AccountPage /> — Phase 0.6 OIDC", () => {
beforeEach(() => {
pushMock.mockClear();
resetStore();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
resetStore();
});
/* ------------------------------------------------------------------------ */
/* 渲染:已登入 */
/* ------------------------------------------------------------------------ */
describe("已登入:個人資料唯讀呈現", () => {
beforeEach(() => {
setUser({
id: "sub-12345",
email: "demo@visionA.local",
name: "Demo User",
});
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_PROFILE_URL",
"http://localhost:5050/account/profile",
);
});
it("顯示 user.idOIDC sub/ email / name全部 readOnly + disabled", () => {
renderAccount();
const idInput = screen.getByTestId("account-userid-input") as HTMLInputElement;
const emailInput = screen.getByTestId(
"account-email-input",
) as HTMLInputElement;
const nameInput = screen.getByTestId(
"account-name-input",
) as HTMLInputElement;
expect(idInput.value).toBe("sub-12345");
expect(emailInput.value).toBe("demo@visionA.local");
expect(nameInput.value).toBe("Demo User");
// 三欄都唯讀
for (const input of [idInput, emailInput, nameInput]) {
expect(input).toHaveAttribute("readonly");
expect(input).toBeDisabled();
expect(input).toHaveAttribute("aria-readonly", "true");
}
});
it("顯示「個人資料由 Innovedus 帳號中心管理」說明", () => {
renderAccount();
expect(
screen.getByText(
/個人資料由 Innovedus 帳號中心管理。如需修改,請至 Innovedus 帳號中心/,
),
).toBeInTheDocument();
});
it("不再渲染:儲存按鈕 / 改密碼欄位 / createdAt placeholder", () => {
renderAccount();
// 舊版 displayName + 儲存按鈕的 testidOF2 之前)
expect(
screen.queryByTestId("account-displayname-input"),
).not.toBeInTheDocument();
// 不該有 password 類型欄位
expect(
document.querySelector('input[type="password"]'),
).not.toBeInTheDocument();
// 不該再有「儲存」按鈕(個人資料區塊內)
expect(screen.queryByText("儲存")).not.toBeInTheDocument();
});
});
/* ------------------------------------------------------------------------ */
/* 「前往 Innovedus 帳號中心」連結 */
/* ------------------------------------------------------------------------ */
describe("「前往 Innovedus 帳號中心」連結", () => {
beforeEach(() => {
setUser({
id: "sub-12345",
email: "demo@visionA.local",
name: "Demo User",
});
});
it("環境變數有設 → 連結 href 為 MC profile URL且開新分頁 + noopener", () => {
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_PROFILE_URL",
"http://localhost:5050/account/profile",
);
renderAccount();
const button = screen.getByTestId("account-edit-at-mc-button");
// Button asChild 時,渲染的會是 <a>href 在 a 上
const link = button.tagName === "A" ? button : button.querySelector("a");
expect(link).not.toBeNull();
expect(link).toHaveAttribute(
"href",
"http://localhost:5050/account/profile",
);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("環境變數未設 → 顯示 disabled hint且不渲染按鈕", () => {
vi.stubEnv("NEXT_PUBLIC_MEMBER_CENTER_PROFILE_URL", "");
renderAccount();
expect(
screen.queryByTestId("account-edit-at-mc-button"),
).not.toBeInTheDocument();
expect(
screen.getByTestId("account-edit-disabled-hint"),
).toBeInTheDocument();
});
});
/* ------------------------------------------------------------------------ */
/* 登出 */
/* ------------------------------------------------------------------------ */
describe("登出按鈕", () => {
it("點按鈕 → 呼叫 authStore.logout() 且 router.push('/login')", async () => {
setUser({
id: "sub-12345",
email: "demo@visionA.local",
name: "Demo User",
});
const logoutSpy = vi
.spyOn(useAuthStore.getState(), "logout")
.mockResolvedValue(undefined);
// 注意spyOn 抓的是當下 snapshot 的 logoutstore 內部呼叫的是 closure 中的 logout
// 因此改用 setState 直接替換 logout action 才能被攔截
useAuthStore.setState({ logout: logoutSpy });
renderAccount();
fireEvent.click(screen.getByTestId("account-logout-button"));
await waitFor(() => {
expect(logoutSpy).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
/* ------------------------------------------------------------------------ */
/* 刪除帳號disabled stub */
/* ------------------------------------------------------------------------ */
describe("刪除帳號按鈕", () => {
it("disabled + 有 tooltip 引導到 Member Center", () => {
setUser({
id: "sub-12345",
email: "demo@visionA.local",
name: "Demo User",
});
renderAccount();
const button = screen.getByTestId("account-delete-button");
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAttribute(
"title",
"請至 Innovedus 帳號中心處理。",
);
});
});
/* ------------------------------------------------------------------------ */
/* user=null fallback */
/* ------------------------------------------------------------------------ */
describe("user 為 nullhydrate 中或未登入瞬間)", () => {
it("不會 throw三個 input 顯示空字串", () => {
setUser(null);
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_PROFILE_URL",
"http://localhost:5050/account/profile",
);
renderAccount();
const idInput = screen.getByTestId("account-userid-input") as HTMLInputElement;
const emailInput = screen.getByTestId(
"account-email-input",
) as HTMLInputElement;
const nameInput = screen.getByTestId(
"account-name-input",
) as HTMLInputElement;
expect(idInput.value).toBe("");
expect(emailInput.value).toBe("");
expect(nameInput.value).toBe("");
});
});
});

View File

@ -0,0 +1,204 @@
"use client";
/**
* /account Phase 0.6 OIDC
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.3user info read-only + Member Center
* - `.autoflow/04-architecture/adr/adr-010-oidc-bff.md`
*
*
* - user.idOIDC sub/ email / name****
* - Innovedus Member Center
* - authStore.logout() backend POST /api/auth/logout cookie session
* router.push('/login')
* - disabled tooltip Member Center
*
*
* 1. ****visionA profile edit Member Center
* displayName createdAt placeholder stub
* 2. UI `<Input readOnly disabled>` aria-readonly
* 3. disabled使 MC
* 4. AuthGate layout /login guard
* `user` null UI fallback
*/
import { useRouter } from "next/navigation";
import { ExternalLink, LogOut, ShieldAlert, UserCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useT } from "@/lib/i18n/context";
import { useAuthStore } from "@/stores/auth-store";
/**
* Member Center profile URL
* disabled
*/
function getMemberCenterProfileUrl(): string {
return process.env.NEXT_PUBLIC_MEMBER_CENTER_PROFILE_URL ?? "";
}
export default function AccountPage() {
const t = useT();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const memberCenterProfileUrl = getMemberCenterProfileUrl();
const hasProfileUrl = memberCenterProfileUrl.length > 0;
async function handleLogout() {
await logout();
router.push("/login");
}
return (
<div className="mx-auto max-w-3xl space-y-6 px-6 py-8" data-testid="account-page">
<div className="space-y-1">
<h1 className="text-2xl font-bold">{t("account.title")}</h1>
<p className="text-muted-foreground text-sm">{t("account.subtitle")}</p>
</div>
{/* 個人資料(唯讀) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<UserCircle2 aria-hidden="true" className="size-4" />
{t("account.profile.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* User IDOIDC sub */}
<div className="space-y-2">
<Label htmlFor="account-user-id">{t("account.profile.userId")}</Label>
<Input
id="account-user-id"
type="text"
value={user?.id ?? ""}
readOnly
disabled
aria-readonly="true"
data-testid="account-userid-input"
/>
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="account-email">{t("account.profile.email")}</Label>
<Input
id="account-email"
type="email"
value={user?.email ?? ""}
readOnly
disabled
aria-readonly="true"
data-testid="account-email-input"
/>
</div>
{/* Display name */}
<div className="space-y-2">
<Label htmlFor="account-name">{t("account.profile.name")}</Label>
<Input
id="account-name"
type="text"
// OIDC `name` claim 可能為空字串UI fallback 顯示 placeholder dash
value={user?.name ?? ""}
placeholder={t("account.profile.namePlaceholder")}
readOnly
disabled
aria-readonly="true"
data-testid="account-name-input"
/>
</div>
{/* 「至帳號中心修改」說明 + 連結 */}
<div className="bg-muted/50 space-y-2 rounded-md border p-3">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("account.profile.managedBy")}
</p>
{hasProfileUrl ? (
<Button
asChild
variant="outline"
size="sm"
data-testid="account-edit-at-mc-button"
>
<a
href={memberCenterProfileUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink aria-hidden="true" className="mr-2 size-4" />
{t("account.profile.editAtMemberCenter")}
</a>
</Button>
) : (
<p
className="text-muted-foreground text-xs"
role="note"
data-testid="account-edit-disabled-hint"
>
{t("account.profile.editDisabledHint")}
</p>
)}
</div>
</CardContent>
</Card>
{/* Session / Logout */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<LogOut aria-hidden="true" className="size-4" />
{t("account.session.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("account.session.description")}
</p>
<Button
variant="outline"
onClick={handleLogout}
data-testid="account-logout-button"
>
<LogOut aria-hidden="true" className="mr-2 size-4" />
{t("auth.action.signOut")}
</Button>
</CardContent>
</Card>
{/* 危險區(雛形 disabled引導到 Member Center */}
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2 text-base">
<ShieldAlert aria-hidden="true" className="size-4" />
{t("account.danger.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("account.danger.description")}
</p>
<Button
variant="destructive"
disabled
aria-disabled="true"
// 用 native title 提供 tooltipvisionA 雛形階段未引入 Tooltip primitive
// (避免為單一 disabled button 增加額外依賴 / 渲染成本)
title={t("account.danger.deleteAccount.tooltip")}
data-testid="account-delete-button"
>
{t("account.danger.deleteAccount")}
</Button>
{/* 給螢幕閱讀器一份說明native title 在多數 SR 上不被讀出) */}
<p className="sr-only">{t("account.danger.deleteAccount.tooltip")}</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,59 @@
"use client";
/**
* /clusters Phase 0 stub
*
*
* - `.autoflow/03-design/pages.md` §9/clusters POC
* - B5`GET /api/clusters` / 501stub
*
* Phase 0
* - Phase 1
* - ClusterCard CRUDPOC stub
* - 使 toastPhase 1
*
* Phase 1 TODO
* - POC ClusterCard / Dialog /
* - ClusterCard RemoteDeviceBadge
* - Degraded
*/
import { Network } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { useT } from "@/lib/i18n/context";
export default function ClustersPage() {
const t = useT();
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8" data-testid="clusters-page">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold">{t("clusters.title")}</h1>
<p className="text-muted-foreground text-sm">{t("clusters.subtitle")}</p>
</div>
<Button
variant="outline"
disabled
aria-label={`${t("clusters.create")}Phase 1`}
onClick={() => toast.info(t("clusters.phase1Toast"))}
>
{t("clusters.create")}
</Button>
</div>
<EmptyState
icon={Network}
title={t("clusters.empty.title")}
description={t("clusters.empty.description")}
action={{
label: t("clusters.phase1Badge"),
onClick: () => toast.info(t("clusters.phase1Toast")),
}}
/>
</div>
);
}

View File

@ -0,0 +1,222 @@
"use client";
/**
* Client /devices/[id]
*
* pages.md §7 + flow-offline-handling.md §5
*
* F6
* - + + RemoteDeviceBadge +
* - bannerremoteStatus=offline
* - + Card
* - / disabled
*
* F6 stub
* - FlashDialog flash tunnel forwardF8
* - DeviceHealthCard / DeviceConnectionLogPhase 1
* - DeviceSettingsCardalias / notes Phase 1
*/
import { useEffect } from "react";
import Link from "next/link";
import { AlertTriangle, ArrowLeft } from "lucide-react";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
interface DeviceDetailClientProps {
id: string;
}
export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
const t = useT();
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
const isLoading = useDeviceStore((s) => s.isLoading);
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
useEffect(() => {
if (id) void fetchDevice(id);
}, [id, fetchDevice]);
if (isLoading && !selectedDevice) {
return (
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-48 rounded-lg" />
<Skeleton className="h-48 rounded-lg" />
</div>
</div>
);
}
// 404 / 未載入雛形selectedDevice 為 null 時顯示占位)
if (!selectedDevice) {
return (
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
<Link href="/devices">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground text-sm">
{t("common.loading")}
</p>
</CardContent>
</Card>
</div>
);
}
const displayName = selectedDevice.alias || selectedDevice.name;
const isOnline = selectedDevice.remoteStatus === "online";
const isOffline = selectedDevice.remoteStatus === "offline";
return (
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
<Link href="/devices">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
{/* bannerflow-offline-handling §5.1
F6 Review Minor #1F7 --warning tokenglobals.css §1.3
PrototypeBanner / UI 使 amber-* */}
{isOffline && (
<div
role="alert"
className="bg-warning-subtle border-warning text-warning-foreground flex items-start gap-3 rounded-lg border p-4"
data-testid="device-offline-banner"
>
<AlertTriangle aria-hidden="true" className="text-warning mt-0.5 size-5 shrink-0" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">
{t("devices.detail.offlineBanner.title")}
</p>
<p className="text-xs opacity-90">
{t("devices.detail.offlineBanner.description")}
</p>
</div>
</div>
)}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold">{displayName}</h1>
{selectedDevice.alias && (
<p className="text-muted-foreground text-sm">{selectedDevice.name}</p>
)}
<RemoteDeviceBadge
status={selectedDevice.remoteStatus}
lastSeenAt={selectedDevice.lastSeenAt ?? null}
errorMessage={selectedDevice.errorMessage ?? null}
/>
</div>
<div className="flex gap-2">
{isOnline && selectedDevice.flashedModel && (
<Link href={`/workspace/${selectedDevice.id}`}>
<Button>{t("devices.openWorkspace")}</Button>
</Link>
)}
{!isOnline && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button disabled>{t("devices.openWorkspace")}</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t("devices.detail.offlineBanner.title")}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<Separator />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("devices.detail.deviceInfo")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow label={t("devices.detail.id")} value={<span className="font-mono text-sm">{selectedDevice.id}</span>} />
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
<InfoRow
label={t("devices.detail.firmware")}
value={selectedDevice.firmwareVersion || t("common.na")}
/>
{selectedDevice.hostName && (
<InfoRow label={t("devices.detail.hostName")} value={selectedDevice.hostName} />
)}
{selectedDevice.pairedAt && (
<InfoRow
label={t("devices.detail.pairedAt")}
value={new Date(selectedDevice.pairedAt).toLocaleString()}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("devices.detail.modelStatus")}
</CardTitle>
</CardHeader>
<CardContent>
{selectedDevice.flashedModel ? (
<div className="space-y-3">
<InfoRow
label={t("devices.flashedModel")}
value={<span className="font-medium">{selectedDevice.flashedModel}</span>}
/>
<p className="text-muted-foreground text-sm">
{t("devices.detail.readyForInference")}
</p>
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("devices.detail.noModelFlashed")}
</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="text-right">{value}</span>
</div>
);
}

View File

@ -0,0 +1,19 @@
/**
* /devices/[id]
*
* pages.md §7 + flow-offline-handling.md §5 UI
*
* Next.js 16 App Router `params` Promise client unwrap
* Server Component id Client Component client
*/
import { DeviceDetailClient } from "./device-detail-client";
export default async function DeviceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <DeviceDetailClient id={id} />;
}

View File

@ -0,0 +1,69 @@
"use client";
/**
* /devices
*
*
* - `.autoflow/03-design/pages.md` §5沿 +
*
* local-tool
* - USB Driverlocal-tool
* - /devices/pairF7
* - udev hint banner
* - DeviceCard RemoteDeviceBadge
* - /devices/pair
*/
import { useEffect } from "react";
import Link from "next/link";
import { Link2, RefreshCw } from "lucide-react";
import { DeviceList } from "@/components/devices/device-list";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
export default function DevicesPage() {
const t = useT();
const devices = useDeviceStore((s) => s.devices);
const isLoading = useDeviceStore((s) => s.isLoading);
const fetchDevices = useDeviceStore((s) => s.fetchDevices);
useEffect(() => {
void fetchDevices();
}, [fetchDevices]);
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t("devices.title")}</h1>
<p className="text-muted-foreground">{t("devices.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void fetchDevices()}
disabled={isLoading}
data-testid="devices-refresh"
aria-label={t("common.retry")}
>
<RefreshCw
aria-hidden="true"
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
</Button>
<Link href="/devices/pair">
<Button data-testid="devices-pair-cta">
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("devices.pairAction")}
</Button>
</Link>
</div>
</div>
<DeviceList devices={devices} loading={isLoading} />
</div>
);
}

View File

@ -0,0 +1,389 @@
"use client";
/**
* /devices/pair
*
*
* - `.autoflow/03-design/flows/flow-pairing.md`
* - `.autoflow/03-design/wireframes/wf-pairing.md`
* - `.autoflow/04-architecture/api/api-spec.md` §2 Pairing
*
* Phase 0 flow-pairing §3
* 1. Step 1 Pairing TokenPOST /api/pairing/token
* 2. Step 2 token + 15 + CLI
* 3. Step 3 GET /api/pairing/status?token=... connected
*
*
* - 501 pairing-store fallback mock token
* - Polling 3 flow-pairing §6.4
* - Timeout 3 flow-pairing §6.4 +
* - toast + router.push('/devices')
*
* flow-pairing §6 wireframe
* - Stepper UI
* Token Card + CLI + Status
* - Step 2/3 / / 使 Stepper
* - Phase 1 Stepperflow-pairing §3
*/
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
CheckCircle2,
Loader2,
Terminal,
TriangleAlert,
} from "lucide-react";
import { toast } from "sonner";
import { PairingTokenCard } from "@/components/pairing/pairing-token-card";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
import { usePairingStore } from "@/stores/pairing-store";
/** Polling 間隔flow-pairing §6.4 */
const POLL_INTERVAL_MS = 3_000;
/** Step 3 最長等待時間flow-pairing §6.4 */
const POLL_TIMEOUT_MS = 3 * 60 * 1000;
type PollPhase = "idle" | "waiting" | "connected" | "timeout";
export default function PairDevicePage() {
const t = useT();
const router = useRouter();
const current = usePairingStore((s) => s.current);
const isGenerating = usePairingStore((s) => s.isGenerating);
const status = usePairingStore((s) => s.status);
const generateToken = usePairingStore((s) => s.generateToken);
const fetchStatus = usePairingStore((s) => s.fetchStatus);
const clearToken = usePairingStore((s) => s.clearToken);
const [pollPhase, setPollPhase] = useState<PollPhase>("idle");
const [elapsedMs, setElapsedMs] = useState(0);
const elapsedStartRef = useRef<number | null>(null);
/* ------- 初始化:頁面進來自動產 tokenflow-pairing §4.3 ------- */
useEffect(() => {
// 頁面初次 mount 時若尚未有 token 就產生一個;有了就直接進 waiting 狀態
async function boot() {
if (!current) {
const generated = await generateToken();
if (generated) {
setPollPhase("waiting");
}
} else {
setPollPhase("waiting");
}
}
void boot();
// 只在 mount 時跑一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ------- 產生 token 成功後進入 waiting開始 polling ------- */
// 用 useEffect 監聽 token 變化來切換 phase。setState 於 effect 內呼叫時,
// react-hooks/set-state-in-effect 要求它必須是「從外部系統 callback 觸發」。
// 但此處是單純對 React statetoken變化的回應 — 雖然 lint 會放寬(只有在
// dep 變化時 setState 一次),我們改為在 generateToken handler 完成後同步推進 phase。
//
// 另用 setInterval 中以 callback 方式更新 elapsedMs符合規則
/* ------- 1 秒 tick 更新「已等待」顯示 ------- */
useEffect(() => {
if (pollPhase !== "waiting") return;
if (elapsedStartRef.current == null) {
elapsedStartRef.current = Date.now();
}
const start = elapsedStartRef.current;
const id = window.setInterval(() => {
setElapsedMs(Date.now() - start);
}, 1_000);
return () => window.clearInterval(id);
}, [pollPhase]);
/* ------- Status polling每 3 秒) ------- */
useEffect(() => {
if (pollPhase !== "waiting" || !current) return;
let cancelled = false;
async function pollOnce() {
if (cancelled || !current) return;
const s = await fetchStatus(current.token);
if (cancelled) return;
// 成功連線 → 切換 UI + toast + 1.5s 後導航到 /devices
if (s?.status === "connected") {
setPollPhase("connected");
toast.success(
t("pairing.toast.pairedSuccess").replace(
"{deviceName}",
s.device?.name ?? t("pairing.device.unknown"),
),
);
window.setTimeout(() => router.push("/devices"), 1_500);
return;
}
// 明確的過期 / 已用flow-pairing §7.1-7.2
if (s?.status === "expired" || s?.status === "used") {
setPollPhase("timeout");
return;
}
}
// 啟動時先 poll 一次
void pollOnce();
const id = window.setInterval(pollOnce, POLL_INTERVAL_MS);
// 3 分鐘 timeout
const timeoutId = window.setTimeout(() => {
if (!cancelled) setPollPhase("timeout");
}, POLL_TIMEOUT_MS);
return () => {
cancelled = true;
window.clearInterval(id);
window.clearTimeout(timeoutId);
};
}, [pollPhase, current, fetchStatus, router, t]);
/* ------- 重新產生 / 取消 / 重試 handlers ------- */
const handleRegenerate = useCallback(async () => {
clearToken();
setPollPhase("idle");
elapsedStartRef.current = null;
setElapsedMs(0);
const next = await generateToken();
if (!next) {
toast.error(t("pairing.toast.generateFailed"));
return;
}
// 新 token 就緒 → 推進到 waiting重新啟動 polling
setPollPhase("waiting");
}, [clearToken, generateToken, t]);
const handleExpire = useCallback(() => {
// 倒數歸零 → 進入 timeout 視覺(使用者可點「重新產生」)
setPollPhase("timeout");
}, []);
const handleRetry = useCallback(() => {
// 重新檢查:不重產 token只回到 waiting 狀態再 poll
// elapsedStartRef 由 pollPhase effect 於進入 waiting 時重置(見上方)
elapsedStartRef.current = null;
setElapsedMs(0);
setPollPhase("waiting");
}, []);
// CLI 範例token 即時嵌入)
const cliExample = current
? `./edge-ai-server --relay-url=ws://localhost:3800 --relay-token=${current.token}`
: `./edge-ai-server --relay-url=ws://localhost:3800 --relay-token=<paste-your-token>`;
async function copyCli() {
try {
await navigator.clipboard.writeText(cliExample);
toast.success(t("pairing.toast.cliCopied"));
} catch {
toast.error(t("common.error"));
}
}
/* ------------------------------- Render ------------------------------- */
return (
<div className="mx-auto max-w-3xl space-y-6 px-6 py-8" data-testid="pair-page">
<Link href="/devices">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
<div className="space-y-1">
<h1 className="text-2xl font-bold">{t("pairing.title")}</h1>
<p className="text-muted-foreground text-sm">{t("pairing.subtitle")}</p>
</div>
{/* Step 1 / 2 — Token Card含倒數 */}
<PairingTokenCard
token={current}
isGenerating={isGenerating}
onRegenerate={handleRegenerate}
onExpire={handleExpire}
/>
{/* Step 2 補充 — CLI 範例 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Terminal aria-hidden="true" className="size-4" />
{t("pairing.cli.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("pairing.cli.description")}
</p>
<div className="bg-muted relative rounded-md p-3 font-mono text-xs">
<pre className="overflow-x-auto whitespace-pre-wrap break-all">
{cliExample}
</pre>
<Button
variant="ghost"
size="sm"
onClick={copyCli}
className="absolute right-2 top-2 h-7 px-2"
>
{t("pairing.cli.copy")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{t("pairing.cli.hint")}
</p>
</CardContent>
</Card>
{/* Step 3 — 狀態卡片 */}
<StatusCard
phase={pollPhase}
elapsedMs={elapsedMs}
onRetry={handleRetry}
onRegenerate={handleRegenerate}
deviceName={status?.device?.name ?? null}
/>
{/* Security warning */}
<div
role="note"
className="bg-warning-subtle text-warning-foreground border-warning flex items-start gap-2 rounded-md border p-3 text-xs"
>
<TriangleAlert aria-hidden="true" className="text-warning mt-0.5 size-4 shrink-0" />
<div className="space-y-1">
<p>{t("pairing.security.warning")}</p>
<p className="opacity-80">{t("pairing.security.oneTime")}</p>
</div>
</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* StatusCard — 等待 / 成功 / 失敗(對應 wf-pairing Step 3 三態) */
/* -------------------------------------------------------------------------- */
interface StatusCardProps {
phase: PollPhase;
elapsedMs: number;
deviceName: string | null;
onRetry: () => void;
onRegenerate: () => void;
}
function formatElapsed(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
const mm = Math.floor(s / 60).toString();
const ss = (s % 60).toString().padStart(2, "0");
return `${mm}:${ss}`;
}
function StatusCard({
phase,
elapsedMs,
deviceName,
onRetry,
onRegenerate,
}: StatusCardProps) {
const t = useT();
if (phase === "idle") return null;
if (phase === "waiting") {
return (
<Card data-testid="pairing-status-waiting">
<CardContent className="space-y-4 py-8 text-center">
<Loader2
aria-hidden="true"
className="text-primary mx-auto size-10 animate-spin"
/>
<div className="space-y-1">
<p className="font-medium" aria-live="polite">
{t("pairing.step3.waiting")}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{t("pairing.step3.elapsed").replace(
"{time}",
formatElapsed(elapsedMs),
)}
</p>
</div>
<ul className="text-muted-foreground space-y-1 text-left text-xs sm:mx-auto sm:max-w-sm">
<li> {t("pairing.step3.hints.running")}</li>
<li> {t("pairing.step3.hints.token")}</li>
<li> {t("pairing.step3.hints.network")}</li>
</ul>
</CardContent>
</Card>
);
}
if (phase === "connected") {
return (
<Card
role="status"
aria-live="assertive"
className="border-status-online/40"
data-testid="pairing-status-connected"
>
<CardContent className="space-y-3 py-8 text-center">
<CheckCircle2
aria-hidden="true"
className="text-status-online mx-auto size-14"
/>
<p className="text-lg font-semibold">{t("pairing.step3.success")}</p>
{deviceName && (
<p className="text-muted-foreground text-sm">
{t("pairing.step3.success.detected")}{deviceName}
</p>
)}
</CardContent>
</Card>
);
}
// timeout
return (
<Card
role="alert"
className="border-destructive/40"
data-testid="pairing-status-timeout"
>
<CardContent className="space-y-4 py-8 text-center">
<TriangleAlert
aria-hidden="true"
className="text-destructive mx-auto size-10"
/>
<div className="space-y-1">
<p className="font-semibold">{t("pairing.step3.failure.timeout")}</p>
<p className="text-muted-foreground text-sm">
{t("pairing.step3.failure.reason")}
</p>
</div>
<ul className="text-muted-foreground space-y-1 text-left text-xs sm:mx-auto sm:max-w-sm">
<li> {t("pairing.step3.hints.running")}</li>
<li> {t("pairing.step3.hints.token")}</li>
<li> {t("pairing.step3.hints.network")}</li>
</ul>
<div className="flex flex-wrap justify-center gap-2">
<Button onClick={onRetry}>{t("pairing.step3.failure.retry")}</Button>
<Button variant="outline" onClick={onRegenerate}>
{t("pairing.regenerate")}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,239 @@
/*
* visionA Cloud 全域樣式 / Design Tokens
*
* 本檔完整沿用 local-tool/frontend/src/app/globals.css Tokensshadcn + Tailwind 4
* 確保 F6 任務直接搬頁面時完全相容
*
* 架構分層參考 .autoflow/03-design/design-tokens.md
* 1. @theme inline CSS 變數映射到 Tailwind 4 的語意 utilitybg-background / text-foreground ...
* 2. :root Light Theme raw tokensshadcn 命名oklch 色彩空間
* 3. .dark Dark Theme 對應 tokens next-themes 透過 <html class="dark"> 切換
* 4. 狀態色 裝置狀態色--status-*為雲端版新增補齊 design-tokens.md §1.3
* 5. @layer base body / 元素預設border / outline / background / foreground
*
* local-tool 的差異
* - 保留 `@import "tw-animate-css"`F3 安裝提供 Dialog / Popover / DropdownMenu 等元件
* 所依賴的 `data-[state=open]:animate-in` animation utility
* - 移除 `@import "shadcn/tailwind.css"` shadcn CLI 的輔助樣式不需要
* - 移除 driver.js 主題覆寫 該元件屬 local-tool 專用功能雲端版不一定會搬
* - 新增 `--status-*` 裝置狀態色 tokens
*/
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
/* --- 核心色彩映射 --- */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* --- 圖表色 --- */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* --- Sidebar --- */
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* --- 裝置狀態色雲端版新增---
* 補齊 design-tokens.md §1.3local-tool 原本散落在 device-status.tsx Tailwind 原生色
* 雲端版將其 token 方便未來視覺迭代與 Dark Mode 調整 */
--color-status-online: var(--status-online);
--color-status-offline: var(--status-offline);
--color-status-reconnecting: var(--status-reconnecting);
--color-status-error: var(--status-error);
--color-status-idle: var(--status-idle);
/* --- 警示warning tokensF7 新增---
* 用於離線 banner雛形 bannerPairing Token 倒數剩餘 10 分鐘等
* --destructive系做區分語意是提醒待處理而非致命錯誤
* subtle = 背景foreground = 對比文字base = icon / border 的強烈色
*/
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-subtle: var(--warning-subtle);
/* --- 字型 ---
* font-sans / font-mono 交由 next/font layout.tsx 注入 CSS 變數
* 此處只映射 Tailwind utility實際值由 @font-face / next/font 決定 */
--font-sans: var(--font-geist-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
--font-mono: var(--font-geist-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace);
/* --- 圓角shadcn 階梯式) --- */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
/* ============================================
* Light Theme 預設
* ============================================ */
:root {
--radius: 0.625rem;
/* 核心表面 */
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* 品牌與互動 */
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
/* 邊框 / 輸入 / Focus */
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* 圖表 */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
/* Sidebar */
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* 裝置狀態色 Light對齊 design-tokens.md §1.3 / §3.7
* online = green-500 : 裝置在線
* offline = gray-400 : 裝置掉線含上次心跳時間
* reconnecting = yellow-400 : 連線中 / 重連中可搭配 animate-pulse
* error = red-500 : 錯誤
* idle = gray-300 : 已偵測但未連線 */
--status-online: oklch(0.696 0.17 162.48);
--status-offline: oklch(0.708 0 0);
--status-reconnecting: oklch(0.852 0.17 91.31);
--status-error: oklch(0.637 0.237 25.33);
--status-idle: oklch(0.82 0 0);
/* 警示系Light — 接近 Tailwind amber-500/-900/-50 的等價 oklch */
--warning: oklch(0.704 0.155 80);
--warning-foreground: oklch(0.32 0.08 70);
--warning-subtle: oklch(0.97 0.04 85);
}
/* ============================================
* Dark Theme next-themes <html> 加上 .dark class 切換
* ============================================ */
.dark {
/* 核心表面 */
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
/* 品牌與互動 */
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
/* 邊框 / 輸入 / Focus */
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
/* 圖表 */
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
/* Sidebar */
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
/* 裝置狀態色 — Dark維持辨識度略微亮化以對應低對比背景 */
--status-online: oklch(0.72 0.17 162.48);
--status-offline: oklch(0.556 0 0);
--status-reconnecting: oklch(0.84 0.14 91);
--status-error: oklch(0.7 0.2 22);
--status-idle: oklch(0.4 0 0);
/* 警示系Dark — 背景轉深、前景轉淺,維持對比 */
--warning: oklch(0.78 0.15 82);
--warning-foreground: oklch(0.95 0.04 85);
--warning-subtle: oklch(0.3 0.06 75 / 30%);
}
/* ============================================
* Base Layer 元素預設繼承 Design Tokens
* ============================================ */
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body {
height: 100%;
}
body {
/* 使用 Tailwind utility + CSS 變數;不再寫死 system-ui修正 F1 review Minor #1。 */
@apply bg-background text-foreground font-sans antialiased;
}
}

View File

@ -0,0 +1,67 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppShell } from "@/components/layout/app-shell";
import { StoreHydration } from "@/components/store-hydration";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { LocaleProvider } from "@/lib/i18n/context";
import { LocaleSync } from "@/lib/i18n/sync";
/**
* Root Layout visionA Cloud
*
* Phase 0
* - LocaleProvider + LocaleSync zh-Hant / en localStorage <html lang>
* - ThemeProvidernext-themesLight / Dark / System <html class="dark">
* - TooltipProviderF4 app Tooltip delayDuration=0 shadcn
* - AppShellF4 PrototypeBanner + Sidebar + Header + main
* - ToasterSonner toast portal
*
*
* - F5auth-store / session-storetunnel
* - F6 local-tool Dashboard / Devices / Models ...
* - F7Pairing
*/
export const metadata: Metadata = {
title: "visionA Cloud (Phase 0 雛形)",
description:
"visionA Cloud 雲端控制台 — 將 local-tool 的使用體驗延伸到雲端,透過 local agent 管理分散的邊緣裝置。",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
// suppressHydrationWarningnext-themes 與 LocaleSync 於 client mount 後才套用使用者偏好,
// 此 hint 關閉 React 對 <html> class/lang 首次 render 差異的警告。
<html lang="zh-Hant" suppressHydrationWarning>
{/*
body globals.css `@layer base body`bg-background / text-foreground / font-sans / antialiased
F2 review Minor #2 `min-h-dvh`
globals.css `height: 100%`滿
*/}
<body className="min-h-dvh">
<LocaleProvider>
<LocaleSync />
{/* F5 新增:從 localStorage 恢復 auth / session / device-preferences state
render DOM side-effect Provider store hydrate
UI Header UserMenu */}
<StoreHydration />
<ThemeProvider>
{/* TooltipProvider ThemeProvider data-[state]
delayDuration=0 hover F6 */}
<TooltipProvider>
<AppShell>{children}</AppShell>
{/* Sonner Toast Portal — 置於 AppShell 之外以覆蓋任何 Dialog / Sheet */}
<Toaster richColors closeButton position="top-right" />
</TooltipProvider>
</ThemeProvider>
</LocaleProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,196 @@
/**
* /login Phase 0.6 OIDC redirect
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.1
* - OF1visionA-frontend login OIDC redirect
*
*
* 1. / / /
* 2. `window.location.assign` URL backend `/api/auth/login`
* 3. `NEXT_PUBLIC_API_BASE`
* 4. disabled target="_blank" + rel="noopener noreferrer"
* 5. **** email / password OF1
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { useAuthStore } from "@/stores/auth-store";
import LoginPage from "./page";
/* -------------------------------------------------------------------------- */
/* Phase 0.7mock next/navigation 的 useRouter */
/* /login 頁新增了「已登入 → 跳回首頁」的 useEffect需要 useRouter context */
/* -------------------------------------------------------------------------- */
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: replaceMock,
push: vi.fn(),
}),
}));
/**
* LocaleProvider useT() context
*/
function renderLogin() {
return render(
<LocaleProvider>
<LoginPage />
</LocaleProvider>,
);
}
/** 重置 auth-store user 狀態,避免測試間污染 */
function resetAuthStore() {
useAuthStore.setState({ user: null, isLoading: false, error: null });
}
describe("<LoginPage /> — Phase 0.6 OIDC redirect", () => {
// 用 spyOn 攔截 window.location.assign避免真的觸發 jsdom navigation會拋警告
let assignSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// jsdom 的 window.location.assign 預設只是 stub先用 vi.fn() 替代以便 assert
assignSpy = vi.fn();
Object.defineProperty(window, "location", {
writable: true,
value: {
...window.location,
assign: assignSpy,
},
});
replaceMock.mockClear();
resetAuthStore();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
resetAuthStore();
});
describe("渲染", () => {
it("顯示「歡迎回來」標題與「使用 Innovedus 帳號登入」說明", () => {
renderLogin();
expect(screen.getByText("歡迎回來")).toBeInTheDocument();
expect(screen.getByText("使用您的 Innovedus 帳號登入")).toBeInTheDocument();
});
it("顯示「登入」主按鈕(觸發 OIDC redirect", () => {
renderLogin();
const button = screen.getByTestId("login-oidc-button");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("登入");
});
it("保留 Phase 0 雛形提示 banner", () => {
renderLogin();
// banner 用 role="note"
expect(screen.getByRole("note")).toBeInTheDocument();
});
it("不再渲染 email / password 輸入欄位OIDC 模式不需要)", () => {
renderLogin();
// 用 queryBy* 確認確實不存在(避免 testid 漏洗)
expect(screen.queryByTestId("login-email-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("login-password-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("login-submit-button")).not.toBeInTheDocument();
// 也不該有 password 類型的 input
expect(
document.querySelector('input[type="password"]'),
).not.toBeInTheDocument();
});
});
describe("「登入」按鈕觸發 OIDC redirect", () => {
it("點按鈕 → window.location.assign 被呼叫並帶上完整 backend URL", () => {
vi.stubEnv("NEXT_PUBLIC_API_BASE", "http://localhost:3721");
renderLogin();
fireEvent.click(screen.getByTestId("login-oidc-button"));
expect(assignSpy).toHaveBeenCalledTimes(1);
expect(assignSpy).toHaveBeenCalledWith("http://localhost:3721/api/auth/login");
});
it("NEXT_PUBLIC_API_BASE 帶 trailing slash 時URL 會去掉重複 slash", () => {
vi.stubEnv("NEXT_PUBLIC_API_BASE", "http://localhost:3721/");
renderLogin();
fireEvent.click(screen.getByTestId("login-oidc-button"));
expect(assignSpy).toHaveBeenCalledWith("http://localhost:3721/api/auth/login");
});
it("NEXT_PUBLIC_API_BASE 未設定時,走相對路徑(同源部署情境)", () => {
vi.stubEnv("NEXT_PUBLIC_API_BASE", "");
renderLogin();
fireEvent.click(screen.getByTestId("login-oidc-button"));
expect(assignSpy).toHaveBeenCalledWith("/api/auth/login");
});
});
describe("註冊連結", () => {
it("環境變數有設 → 連結 href 為 Member Center URL並開新分頁", () => {
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL",
"http://localhost:5050/account/register",
);
renderLogin();
const link = screen.getByTestId("login-register-link");
expect(link).toHaveAttribute("href", "http://localhost:5050/account/register");
expect(link).toHaveAttribute("target", "_blank");
// 安全:開新分頁時必須帶 noopener 防 reverse tabnabbing
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(link).not.toHaveAttribute("aria-disabled", "true");
});
it("環境變數未設 → 連結 disabledaria-disabled + href=#", () => {
vi.stubEnv("NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL", "");
renderLogin();
const link = screen.getByTestId("login-register-link");
expect(link).toHaveAttribute("href", "#");
expect(link).toHaveAttribute("aria-disabled", "true");
// 不該帶 target="_blank",避免使用者誤以為會跳到註冊頁
expect(link).not.toHaveAttribute("target");
});
it("文案「還沒有帳號前往註冊」zh-Hant 預設)", () => {
renderLogin();
// noAccount + registerLink 兩段文案都要在
expect(screen.getByText(/還沒有帳號/)).toBeInTheDocument();
expect(screen.getByText("前往註冊")).toBeInTheDocument();
});
});
/* ------------------------------------------------------------------------ */
/* Phase 0.7:已登入使用者進到 /login → 自動跳回首頁 */
/* (見 .autoflow/05-implementation/phase-0.7-frontend-fix.md */
/* ------------------------------------------------------------------------ */
describe("已登入使用者進到 /login → 自動跳回首頁", () => {
it("user 為 null未登入→ 不呼叫 router.replace", () => {
// resetAuthStore 已將 user 設為 null
renderLogin();
expect(replaceMock).not.toHaveBeenCalled();
});
it("user 已存在(登入後又開 /login→ 呼叫 router.replace('/')", () => {
useAuthStore.setState({
user: { id: "u1", email: "u@example.com", name: "U" },
});
renderLogin();
expect(replaceMock).toHaveBeenCalledWith("/");
});
});
});

View File

@ -0,0 +1,152 @@
"use client";
/**
* /login Phase 0.6 OIDC redirect
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.1Frontend
* - `.autoflow/04-architecture/adr/adr-010-oidc-bff.md`
* - Phase 0 email/password StaticAuthProvider
*
*
* - 使 origin redirect backend `/api/auth/login`
* - backend PKCE / state / nonce 302 Innovedus Member Center
* - MC backend `/api/auth/callback` backend cookie 302 frontend `/`
*
*
* 1. ** next router.push** origin page navigation
* redirect Set-Cookie `window.location.assign`
* 2. **frontend OIDC token** token / PKCE backend
* 3. **** Member Center Phase 0.6 Member Center
* register URL fallback `#`
* 4. **** Phase 0 bannerOF1 backend MCUI
* 5. ** useAuthStore**OF1 auth-store / state / handler /
* OF2 auth-store cookie session
*/
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle, LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
import { useAuthStore } from "@/stores/auth-store";
/**
* OIDC login redirect URL
*
* - frontend `localhost:3000` backend `localhost:3721` URL
* origin frontend
* - Production `NEXT_PUBLIC_API_BASE` URL
* `/api/auth/login` origin
*/
function buildLoginUrl(): string {
const apiBase = process.env.NEXT_PUBLIC_API_BASE ?? "";
// 去掉可能的 trailing slash再接上 /api/auth/login
const base = apiBase.replace(/\/$/, "");
return `${base}/api/auth/login`;
}
/**
* Member Center URL
* `#` `aria-disabled`
*/
function getMemberCenterRegisterUrl(): string {
return process.env.NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL || "#";
}
export default function LoginPage() {
const t = useT();
const router = useRouter();
const memberCenterRegisterUrl = getMemberCenterRegisterUrl();
const hasRegisterUrl = memberCenterRegisterUrl !== "#";
// Phase 0.7 stage deployment fix見 .autoflow/05-implementation/phase-0.7-frontend-fix.md
// 已登入使用者進到 /login → 自動跳回首頁
// 場景:
// 1. 使用者登入後手動點 /login 或 typing URL → 不該再次走 OIDC flow
// 2. OIDC 完成後 backend 302 回 / 而非 /login但若使用者前次離開時停在 /login
// browser 重新打開時會先跑 layout 的 StoreHydrationhydrate me→ 已登入 → 跳走
// 注意StoreHydration 在 RootLayout 跑hydrate() 是 async
// 此 effect 用 user 當依賴hydrate 完 user 寫入 store 後會 re-run 並觸發 redirect
const user = useAuthStore((s) => s.user);
useEffect(() => {
if (user) {
router.replace("/");
}
}, [user, router]);
/**
* origin redirect backend OIDC login endpoint
* 使 `window.location.assign` `router.push`
* Next.js origin page navigation
*/
function handleSignIn() {
window.location.assign(buildLoginUrl());
}
return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-md space-y-8" data-testid="login-page">
{/* 品牌 */}
<div className="space-y-2 text-center">
<div
aria-hidden="true"
className="bg-primary text-primary-foreground mx-auto grid size-12 place-items-center rounded-lg text-lg font-bold"
>
vA
</div>
<h1 className="text-2xl font-bold">{t("app.title")}</h1>
</div>
{/* Phase 0 雛形提示 banner保留OF1 階段 backend 尚未接 MC */}
<div
role="note"
className="bg-warning-subtle text-warning-foreground border-warning flex items-start gap-2 rounded-md border p-3 text-xs"
>
<AlertTriangle aria-hidden="true" className="text-warning mt-0.5 size-4 shrink-0" />
<p>{t("auth.login.prototypeHint")}</p>
</div>
<Card>
<CardContent className="space-y-6 py-8 text-center">
{/* 文案 */}
<div className="space-y-2">
<h2 className="text-xl font-semibold">{t("auth.login.welcomeBack")}</h2>
<p className="text-muted-foreground text-sm">{t("auth.login.signInWithMC")}</p>
</div>
{/* 主要按鈕:跨 origin redirect 到 backend OIDC login */}
<Button
type="button"
size="lg"
className="w-full"
onClick={handleSignIn}
data-testid="login-oidc-button"
>
<LogIn aria-hidden="true" className="mr-2 size-4" />
{t("auth.login.button")}
</Button>
{/* 註冊連結:跳到 Member Center 註冊頁OF3 後可能調整) */}
<p className="text-muted-foreground text-sm">
{t("auth.login.noAccount")}{" "}
<a
href={memberCenterRegisterUrl}
// 跳到外部 Member Center 域名,需要 noopener 防 reverse tabnabbing
{...(hasRegisterUrl
? { target: "_blank", rel: "noopener noreferrer" }
: { "aria-disabled": "true" })}
className="text-primary font-medium underline-offset-4 hover:underline aria-disabled:cursor-not-allowed aria-disabled:opacity-60"
data-testid="login-register-link"
>
{t("auth.login.registerLink")}
</a>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,188 @@
"use client";
/**
* /models/[id]
*
* pages.md §8.2F6 + / F7+
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useT } from "@/lib/i18n/context";
import { useModelStore } from "@/stores/model-store";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
function formatFileSize(bytes: number): string {
if (!bytes) return "—";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
interface ModelDetailClientProps {
id: string;
}
export function ModelDetailClient({ id }: ModelDetailClientProps) {
const t = useT();
const router = useRouter();
const selectedModel = useModelStore((s) => s.selectedModel);
const isLoading = useModelStore((s) => s.isLoading);
const fetchModel = useModelStore((s) => s.fetchModel);
const deleteModel = useModelStore((s) => s.deleteModel);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (id) void fetchModel(id);
}, [id, fetchModel]);
async function handleDelete() {
setDeleting(true);
const ok = await deleteModel(id);
setDeleting(false);
if (ok) {
toast.success(t("common.save"));
router.push("/models");
} else {
toast.error(t("common.error"));
}
}
if (isLoading && !selectedModel) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-48 rounded-lg" />
</div>
);
}
if (!selectedModel) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
<Link href="/models">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground text-sm">{t("common.loading")}</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
<Link href="/models">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<h1 className="text-2xl font-bold">{selectedModel.name}</h1>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedModel.targetChip.toUpperCase()}</Badge>
<Badge variant={selectedModel.status === "ready" ? "default" : "secondary"}>
{t(`models.status.${selectedModel.status}`)}
</Badge>
{selectedModel.source !== "uploaded" && (
<Badge variant="secondary">
{t(`models.source.${selectedModel.source}`)}
</Badge>
)}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deleting}>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
<AlertDialogDescription>{selectedModel.name}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("models.detail.description")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{selectedModel.description ? (
<p className="text-sm">{selectedModel.description}</p>
) : (
<p className="text-muted-foreground text-sm"></p>
)}
<div className="grid grid-cols-2 gap-3 pt-3 text-sm">
<InfoRow label={t("models.size")} value={formatFileSize(selectedModel.fileSize)} />
<InfoRow
label={t("models.createdAt")}
value={selectedModel.createdAt ? new Date(selectedModel.createdAt).toLocaleString() : "—"}
/>
{selectedModel.version && (
<InfoRow label={t("models.detail.version")} value={selectedModel.version} />
)}
{selectedModel.checksum && (
<InfoRow
label={t("models.detail.checksum")}
value={<span className="font-mono text-xs">{selectedModel.checksum}</span>}
/>
)}
</div>
</CardContent>
</Card>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className="text-right">{value}</span>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { ModelDetailClient } from "./model-detail-client";
export default async function ModelDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <ModelDetailClient id={id} />;
}

View File

@ -0,0 +1,56 @@
"use client";
/**
* /models
*
* pages.md §8.1flow-model-upload.md §4.1
*/
import { useEffect, useMemo, useState } from "react";
import {
ModelFilters,
type ModelFilterValue,
} from "@/components/models/model-filters";
import { ModelGrid } from "@/components/models/model-grid";
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
import { useT } from "@/lib/i18n/context";
import { useModelStore } from "@/stores/model-store";
export default function ModelsPage() {
const t = useT();
const models = useModelStore((s) => s.models);
const isLoading = useModelStore((s) => s.isLoading);
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]);
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t("models.title")}</h1>
<p className="text-muted-foreground">{t("models.subtitle")}</p>
</div>
<ModelUploadDialog />
</div>
<ModelFilters value={filter} onChange={setFilter} />
<ModelGrid models={filtered} loading={isLoading} />
</div>
);
}

View File

@ -0,0 +1,165 @@
"use client";
/**
* Dashboard visionA Cloud
*
*
* - `.autoflow/03-design/pages.md` §1Dashboard 沿 + 調
* - `.autoflow/03-design/flows/flow-offline-handling.md` §4.3
*
* F6
* - 4 StatCard / / /
* - ConnectedDevicesList + ActivityTimeline
* - Quick Actions / /
* - EmptyState /devices/pair
*
* Q6 使
* - OnboardingDialogPhase 1
* - StatCardremoteStatus=online
* - ConnectedDevicesList RemoteDeviceBadge
*/
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Boxes, Cable, Link2, Upload, Zap } from "lucide-react";
import { ActivityTimeline } from "@/components/dashboard/activity-timeline";
import { ConnectedDevicesList } from "@/components/dashboard/connected-devices-list";
import { StatCard } from "@/components/dashboard/stat-card";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useT } from "@/lib/i18n/context";
import { useActivityStore } from "@/stores/activity-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDeviceStore } from "@/stores/device-store";
import { useModelStore } from "@/stores/model-store";
export default function DashboardPage() {
const t = useT();
const router = useRouter();
// OF2User shape 改為 OIDC claimsid/email/namename / email 都可能 undefined
// MC id_token 沒帶該 claimbackend Go struct `omitempty`)。
// 依序 fallbackname → email → idOIDC sub→ "使用者",確保永遠是非空字串
// 避免 `String.replace("{name}", undefined)` 顯示 "Hi undefined"
const displayName = useAuthStore((s) => {
const u = s.user;
if (!u) return "訪客";
if (u.name && u.name.length > 0) return u.name;
if (u.email && u.email.length > 0) return u.email;
if (u.id && u.id.length > 0) return u.id;
return "使用者";
});
const devices = useDeviceStore((s) => s.devices);
const fetchDevices = useDeviceStore((s) => s.fetchDevices);
const models = useModelStore((s) => s.models);
const fetchModels = useModelStore((s) => s.fetchModels);
const activities = useActivityStore((s) => s.activities);
// 初次 mount 時拉一次;後續 F7/F8 會接 WS 推送 + polling
useEffect(() => {
void fetchDevices();
void fetchModels();
}, [fetchDevices, fetchModels]);
const onlineDeviceCount = devices.filter((d) => d.remoteStatus === "online").length;
const flashCount = activities.filter((a) => a.type === "flash_complete").length;
const welcome = t("home.welcome").replace("{name}", displayName);
const isFirstVisit = devices.length === 0 && activities.length === 0;
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-8">
<div>
<h1 className="text-2xl font-bold">{welcome}</h1>
<p className="text-muted-foreground">{t("dashboard.subtitle")}</p>
</div>
{isFirstVisit ? (
<EmptyState
icon={Link2}
title={t("dashboard.empty.title")}
description={t("dashboard.empty.description")}
action={{
label: t("dashboard.empty.action"),
// F6 Review Minor #2F7 修):改走 SPA router.push避免整頁 reload
onClick: () => router.push("/devices/pair"),
}}
/>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title={t("dashboard.models")}
value={models.length}
icon={Boxes}
iconColor="text-chart-1"
href="/models"
/>
<StatCard
title={t("dashboard.devices")}
value={devices.length}
icon={Cable}
iconColor="text-chart-2"
href="/devices"
/>
<StatCard
title={t("dashboard.connected")}
value={onlineDeviceCount}
icon={Cable}
iconColor="text-status-online"
href="/workspace"
/>
<StatCard
title={t("dashboard.flashes")}
value={flashCount}
icon={Zap}
iconColor="text-chart-3"
/>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ConnectedDevicesList />
<ActivityTimeline />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.quickActions")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<Link href="/models">
<Button variant="outline">
<Boxes aria-hidden="true" className="mr-2 size-4" />
{t("dashboard.browseModels")}
</Button>
</Link>
<Link href="/devices">
<Button variant="outline">
<Cable aria-hidden="true" className="mr-2 size-4" />
{t("dashboard.manageDevices")}
</Button>
</Link>
<Link href="/devices/pair">
<Button>
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("dashboard.pairDevice")}
</Button>
</Link>
<Link href="/models">
<Button variant="ghost">
<Upload aria-hidden="true" className="mr-2 size-4" />
{t("dashboard.uploadModel")}
</Button>
</Link>
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}

View File

@ -0,0 +1,125 @@
"use client";
/**
* /register Phase 0.6 OIDC Member Center
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.5 Architect +
* redirect使 visionA
* - `.autoflow/04-architecture/adr/adr-010-oidc-bff.md`
*
*
* - Innovedus page navigation
* Member Center URL`window.location.assign`使 MC
* - 使 visionA `/login` OIDC
* - email / password / auth API
*
*
* 1. `window.location.assign` origin next router.push
* 2. disabled + hint `#`
* 3. PrototypeBanner layout
*/
import Link from "next/link";
import { ArrowLeft, ExternalLink, UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
/**
* Member Center URL
* disable
*/
function getMemberCenterRegisterUrl(): string {
return process.env.NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL ?? "";
}
export default function RegisterPage() {
const t = useT();
const memberCenterRegisterUrl = getMemberCenterRegisterUrl();
const hasRegisterUrl = memberCenterRegisterUrl.length > 0;
/**
* Member Center
* `window.location.assign` page navigation
* - originbrowser OIDC
* - Next router
*/
function handleGoToMemberCenter() {
if (!hasRegisterUrl) return;
window.location.assign(memberCenterRegisterUrl);
}
return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-md space-y-6" data-testid="register-page">
{/* 品牌 */}
<div className="space-y-2 text-center">
<div
aria-hidden="true"
className="bg-primary text-primary-foreground mx-auto grid size-12 place-items-center rounded-lg text-lg font-bold"
>
vA
</div>
<h1 className="text-2xl font-bold">{t("auth.register.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("auth.register.description")}
</p>
</div>
<Card>
<CardContent className="space-y-5 py-8">
<div className="space-y-3 text-center">
<div className="bg-muted mx-auto grid size-14 place-items-center rounded-full">
<UserPlus
aria-hidden="true"
className="text-muted-foreground size-6"
/>
</div>
<p className="text-foreground text-sm leading-relaxed">
{t("auth.register.howTo")}
</p>
</div>
<Button
type="button"
size="lg"
className="w-full"
onClick={handleGoToMemberCenter}
disabled={!hasRegisterUrl}
aria-disabled={!hasRegisterUrl}
data-testid="register-go-to-mc-button"
>
<ExternalLink aria-hidden="true" className="mr-2 size-4" />
{t("auth.register.button")}
</Button>
{!hasRegisterUrl && (
<p
className="text-muted-foreground text-center text-xs"
role="note"
data-testid="register-disabled-hint"
>
{t("auth.register.disabledHint")}
</p>
)}
</CardContent>
</Card>
{/* 已有帳號?登入 */}
<p className="text-muted-foreground text-center text-xs">
{t("auth.register.alreadyHaveAccount")}{" "}
<Link
href="/login"
className="text-foreground inline-flex items-center underline-offset-4 hover:underline"
data-testid="register-back-to-login-link"
>
<ArrowLeft aria-hidden="true" className="mr-1 size-3" />
{t("auth.register.loginLink")}
</Link>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
/**
* /register Phase 0.6 OIDC Member Center
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.5
* - OF3visionA-frontend register
*
*
* 1. / / Innovedus /
* 2. **** email / password
* 3. window.location.assign MEMBER_CENTER_REGISTER_URL
* 4. disabled + hint
* 5. /login
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import RegisterPage from "./page";
function renderRegister() {
return render(
<LocaleProvider>
<RegisterPage />
</LocaleProvider>,
);
}
describe("<RegisterPage /> — Phase 0.6 OIDC 導向 Member Center", () => {
// 攔截 window.location.assignjsdom 的 assign 是 stub無法 assert
let assignSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
assignSpy = vi.fn();
Object.defineProperty(window, "location", {
writable: true,
value: {
...window.location,
assign: assignSpy,
},
});
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("渲染", () => {
it("顯示「註冊 visionA」標題與「使用 Innovedus 統一帳號」說明", () => {
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL",
"http://localhost:5050/account/register",
);
renderRegister();
expect(screen.getByText("註冊 visionA")).toBeInTheDocument();
expect(
screen.getByText(/visionA 使用 Innovedus 統一帳號系統/),
).toBeInTheDocument();
expect(
screen.getByText(
/請至 Innovedus 帳號中心註冊,註冊完成後即可回到 visionA 登入/,
),
).toBeInTheDocument();
});
it("顯示「前往 Innovedus 帳號中心」主按鈕", () => {
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL",
"http://localhost:5050/account/register",
);
renderRegister();
const button = screen.getByTestId("register-go-to-mc-button");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("前往 Innovedus 帳號中心");
expect(button).not.toBeDisabled();
});
it("不再渲染 email / password / confirmPassword 表單欄位", () => {
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL",
"http://localhost:5050/account/register",
);
renderRegister();
// 各種型別 input 都不該存在
expect(document.querySelector('input[type="email"]')).not.toBeInTheDocument();
expect(document.querySelector('input[type="password"]')).not.toBeInTheDocument();
// 也不該有舊版的 testid
expect(screen.queryByTestId("register-email-input")).not.toBeInTheDocument();
expect(screen.queryByTestId("register-password-input")).not.toBeInTheDocument();
expect(
screen.queryByTestId("register-confirm-password-input"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("register-submit-button")).not.toBeInTheDocument();
});
it("「已有帳號?登入」連結指向 /login", () => {
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL",
"http://localhost:5050/account/register",
);
renderRegister();
const link = screen.getByTestId("register-back-to-login-link");
expect(link).toHaveAttribute("href", "/login");
});
});
describe("互動:點按鈕跳轉到 Member Center", () => {
it("環境變數有設 → window.location.assign 被呼叫且帶上完整 URL", () => {
vi.stubEnv(
"NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL",
"http://localhost:5050/account/register",
);
renderRegister();
fireEvent.click(screen.getByTestId("register-go-to-mc-button"));
expect(assignSpy).toHaveBeenCalledTimes(1);
expect(assignSpy).toHaveBeenCalledWith(
"http://localhost:5050/account/register",
);
});
it("環境變數未設 → 按鈕 disabled、顯示 hint、點擊不觸發 assign", () => {
vi.stubEnv("NEXT_PUBLIC_MEMBER_CENTER_REGISTER_URL", "");
renderRegister();
const button = screen.getByTestId("register-go-to-mc-button");
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
// hint 出現
expect(screen.getByTestId("register-disabled-hint")).toBeInTheDocument();
// disabled 按鈕點擊不該觸發 assignfireEvent.click 在 disabled 上仍會觸發 onClick
// 因此 handler 內部多一層 hasRegisterUrl 守衛;這裡同時 assert 兩件事)
fireEvent.click(button);
expect(assignSpy).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,151 @@
"use client";
/**
* /settings
*
* pages.md §8.5
* 調
* - 使 Python runtimePhase 1 Local Agent
* - ServerStatusDashboard / ServerLogViewerlocal-tool 使 Q6
* - Backend URL API Endpoint
*/
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getApiBaseUrl, getWsBaseUrl } from "@/lib/api";
import { useLocale, useT } from "@/lib/i18n/context";
import { SUPPORTED_LOCALES } from "@/lib/i18n/types";
import { useTheme } from "next-themes";
export default function SettingsPage() {
const t = useT();
const { locale, setLocale } = useLocale();
const { theme, resolvedTheme, setTheme } = useTheme();
const [apiUrl, setApiUrl] = useState("");
const [wsUrl, setWsUrl] = useState("");
// getApiBaseUrl / getWsBaseUrl 讀 env只在 client 端 mount 後顯示(避免 SSR mismatch
// React 19 lintreact-hooks/set-state-in-effect禁止 effect body 直接 setState
// 透過 queueMicrotask 推到 microtask queue 即可通過。
useEffect(() => {
queueMicrotask(() => {
setApiUrl(getApiBaseUrl());
setWsUrl(getWsBaseUrl());
});
}, []);
return (
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
<div>
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
<p className="text-muted-foreground">{t("settings.subtitle")}</p>
</div>
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">{t("settings.tabs.general")}</TabsTrigger>
<TabsTrigger value="advanced">{t("settings.tabs.advanced")}</TabsTrigger>
</TabsList>
{/* ─── General ─── */}
<TabsContent value="general" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("settings.general.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="locale-select">{t("settings.general.language")}</Label>
<Select
value={locale}
onValueChange={(v) => setLocale(v as typeof SUPPORTED_LOCALES[number])}
>
<SelectTrigger id="locale-select" className="w-60">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LOCALES.map((l) => (
<SelectItem key={l} value={l}>
{l === "zh-Hant" ? "繁體中文" : "English"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="theme-select">{t("settings.general.theme")}</Label>
<Select value={theme ?? "system"} onValueChange={setTheme}>
<SelectTrigger id="theme-select" className="w-60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t("theme.light")}</SelectItem>
<SelectItem value="dark">{t("theme.dark")}</SelectItem>
<SelectItem value="system">{t("theme.system")}</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{t("settings.general.themeHint")}
{resolvedTheme && ` · ${t(`theme.${resolvedTheme as "light" | "dark"}`)}`}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ─── Advanced ─── */}
<TabsContent value="advanced" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("settings.advanced.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.advanced.apiEndpointHint")}
</p>
<div className="space-y-2">
<Label>{t("settings.advanced.apiUrl")}</Label>
<Input value={apiUrl} readOnly className="bg-muted font-mono text-sm" />
</div>
<div className="space-y-2">
<Label>{t("settings.advanced.wsUrl")}</Label>
<Input value={wsUrl} readOnly className="bg-muted font-mono text-sm" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("settings.advanced.about")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t("settings.advanced.version")}</span>
<span className="font-medium">v0.1.0</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t("settings.advanced.platform")}</span>
<span className="font-medium">visionA Cloud</span>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { WorkspaceClient } from "./workspace-client";
export default async function WorkspacePage({
params,
}: {
params: Promise<{ deviceId: string }>;
}) {
const { deviceId } = await params;
return <WorkspaceClient deviceId={deviceId} />;
}

View File

@ -0,0 +1,203 @@
"use client";
/**
* Client /workspace/[deviceId]
*
* pages.md §8.4flow-offline-handling.md §6
*
* F6 /
* - + + RemoteDeviceBadge + /
* - TabsCamera / Image / Video / Batch Camera stub Phase 1
* - Camera tab placeholderF8 MJPEG stream + InferencePanel
* - remoteStatus != online +
*
* F6 Phase 1 / F8
* - MJPEG stream tunnel local agent
* - InferencePanel classification result / performance metrics
* - start/stop inference WS
* - Camera SourceSelector
* - Confidence slider / overlay
*
* Workspace
* remoteStatus != online offline
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, WifiOff } from "lucide-react";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api, ApiError } from "@/lib/api";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
import { toast } from "sonner";
interface WorkspaceClientProps {
deviceId: string;
}
export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
const t = useT();
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
const isLoading = useDeviceStore((s) => s.isLoading);
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
const [isRunning, setIsRunning] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (deviceId) void fetchDevice(deviceId);
}, [deviceId, fetchDevice]);
async function handleStart() {
setBusy(true);
try {
await api.post(`/api/devices/${encodeURIComponent(deviceId)}/inference/start`);
setIsRunning(true);
} catch (err) {
// 雛形後端可能 501不當致命錯誤
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
toast.info("雛形start inference 尚未接後端");
setIsRunning(true);
} else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
}
async function handleStop() {
setBusy(true);
try {
await api.post(`/api/devices/${encodeURIComponent(deviceId)}/inference/stop`);
} catch (err) {
if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setIsRunning(false);
setBusy(false);
}
}
if (isLoading && !selectedDevice) {
return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-[60vh] rounded-lg" />
</div>
);
}
// 裝置尚未載入雛形selectedDevice 可能為 null— 簡易占位
const device = selectedDevice;
const isOnline = device?.remoteStatus === "online";
return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href={device ? `/devices/${device.id}` : "/devices"}>
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("workspace.header.backToDevices")}
</Button>
</Link>
<h1 className="text-xl font-bold">
{t("workspace.header.title")} · {device?.alias || device?.name || deviceId}
</h1>
{device && (
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
/>
)}
</div>
<div className="flex gap-2">
{isRunning ? (
<Button variant="destructive" onClick={handleStop} disabled={busy}>
{t("workspace.inference.stop")}
</Button>
) : (
<Button onClick={handleStart} disabled={busy || !isOnline}>
{t("workspace.inference.start")}
</Button>
)}
</div>
</div>
<Tabs defaultValue="camera" className="w-full">
<TabsList>
<TabsTrigger value="camera">{t("workspace.tabs.camera")}</TabsTrigger>
<TabsTrigger value="image" disabled>
{t("workspace.tabs.image")}
</TabsTrigger>
<TabsTrigger value="video" disabled>
{t("workspace.tabs.video")}
</TabsTrigger>
<TabsTrigger value="batch" disabled>
{t("workspace.tabs.batch")}
</TabsTrigger>
</TabsList>
<TabsContent value="camera" className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
<Card className="min-h-[60vh]">
<CardContent className="bg-muted/40 grid h-full min-h-[60vh] place-items-center p-6 text-center">
<p className="text-muted-foreground text-sm">
{t("workspace.placeholder.cameraComingSoon")}
</p>
</CardContent>
</Card>
<Card className="min-h-[60vh]">
<CardHeader>
<CardTitle className="text-base">Inference</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{/* 雛形InferencePanel stub。F8 補 ClassificationResult + PerformanceMetrics */}
Phase 1
</p>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* 裝置掉線遮罩flow-offline-handling §6.2 */}
{device && !isOnline && (
<div
role="alertdialog"
aria-labelledby="workspace-offline-title"
className="bg-background/80 fixed inset-0 z-50 grid place-items-center backdrop-blur-sm"
data-testid="workspace-offline-overlay"
>
<Card className="max-w-md">
<CardContent className="space-y-4 py-8 text-center">
<WifiOff
aria-hidden="true"
className="text-muted-foreground mx-auto size-12"
/>
<h2 id="workspace-offline-title" className="text-xl font-semibold">
{t("workspace.offline.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("workspace.offline.description").replace(
"{deviceName}",
device.alias || device.name,
)}
</p>
<Link href="/devices">
<Button>{t("workspace.offline.backToList")}</Button>
</Link>
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,90 @@
"use client";
/**
* /workspace
*
* pages.md §8.3
* 調 RemoteDeviceBadge opacity-50
*/
import { useEffect } from "react";
import Link from "next/link";
import { Play } from "lucide-react";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useRouter } from "next/navigation";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
export default function WorkspaceIndexPage() {
const t = useT();
const router = useRouter();
const devices = useDeviceStore((s) => s.devices);
const fetchDevices = useDeviceStore((s) => s.fetchDevices);
useEffect(() => {
void fetchDevices();
}, [fetchDevices]);
const online = devices.filter((d) => d.remoteStatus === "online");
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
<div>
<h1 className="text-2xl font-bold">{t("workspace.title")}</h1>
<p className="text-muted-foreground">{t("workspace.subtitle")}</p>
</div>
{online.length === 0 ? (
<EmptyState
icon={Play}
title={t("workspace.empty.title")}
description={t("workspace.empty.description")}
action={{
label: t("workspace.empty.action"),
onClick: () => router.push("/devices"),
}}
/>
) : (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
data-testid="workspace-device-grid"
>
{online.map((d) => (
<Link key={d.id} href={`/workspace/${d.id}`}>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<CardTitle className="truncate text-base">
{d.alias || d.name}
</CardTitle>
<RemoteDeviceBadge
status={d.remoteStatus}
lastSeenAt={d.lastSeenAt ?? null}
size="sm"
showLastSeen={false}
/>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">{d.type}</p>
{d.flashedModel && (
<p className="mt-1 truncate text-sm font-medium">
{d.flashedModel}
</p>
)}
<Button size="sm" className="mt-3">
{t("workspace.inference.start")}
</Button>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
此目錄存放業務元件devices / models / dashboard 等),由 F6 任務從 local-tool 搬入。

View File

@ -0,0 +1,69 @@
/**
* RemoteDeviceBadge
*
*
* - status render + data-status
* - `unknown` F5/F6 data-status unknown
* - + icon + dot
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { RemoteDeviceBadge } from "./remote-device-badge";
function renderWithLocale(ui: React.ReactElement) {
return render(<LocaleProvider>{ui}</LocaleProvider>);
}
describe("<RemoteDeviceBadge />", () => {
it("online 狀態顯示「在線」文字", () => {
renderWithLocale(<RemoteDeviceBadge status="online" />);
const badge = screen.getByRole("status");
expect(badge).toHaveAttribute("data-status", "online");
expect(badge).toHaveTextContent("在線");
});
it("offline 狀態顯示「離線」文字", () => {
renderWithLocale(<RemoteDeviceBadge status="offline" />);
expect(screen.getByRole("status")).toHaveAttribute("data-status", "offline");
expect(screen.getByText("離線")).toBeInTheDocument();
});
it("reconnecting 狀態顯示「重新連線中」", () => {
renderWithLocale(<RemoteDeviceBadge status="reconnecting" />);
const badge = screen.getByRole("status");
expect(badge).toHaveAttribute("data-status", "reconnecting");
expect(badge).toHaveTextContent("重新連線中");
});
it("unknown 狀態文字與 offline 相同(對齊雛形決策)但 data-status 保留為 unknown", () => {
renderWithLocale(<RemoteDeviceBadge status="unknown" />);
const badge = screen.getByRole("status");
expect(badge).toHaveAttribute("data-status", "unknown");
// 視覺上顯示「離線」文字
expect(badge).toHaveTextContent("離線");
});
it("error 狀態 + errorMessage 進入 aria-label / title", () => {
renderWithLocale(
<RemoteDeviceBadge status="error" errorMessage="無法建立 tunnel" />,
);
const badge = screen.getByRole("status");
expect(badge).toHaveAttribute("data-status", "error");
expect(badge.getAttribute("title")).toContain("無法建立 tunnel");
});
it("有 aria-live=polite 供 SR 公告狀態變化", () => {
renderWithLocale(<RemoteDeviceBadge status="online" />);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
});
it("offline 狀態沒有 lastSeenAt 時顯示「從未連線」", () => {
// 初始 render subText 會是空nowMs=0 還沒 tick在 DOM 中看不到文字,但 title 也只帶狀態
// 這個測試只是確保 render 不 crash且 data-status 正確
renderWithLocale(<RemoteDeviceBadge status="offline" showLastSeen={true} />);
expect(screen.getByRole("status")).toHaveAttribute("data-status", "offline");
});
});

View File

@ -0,0 +1,158 @@
"use client";
/**
* RemoteDeviceBadge
*
*
* - `.autoflow/03-design/components.md` §10.3
* - `.autoflow/03-design/flows/flow-offline-handling.md` §2
*
*
* - dot F2 --status-* token+ +
* - `unknown`
* - `error` dot + tooltip errorMessage
*
*
* - `role="status"` + `aria-live="polite"` SR
* - + + icon//design-review M2
*/
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { RemoteStatus } from "@/stores/device-store";
export interface RemoteDeviceBadgeProps {
status: RemoteStatus;
/** ISO 8601 timestamp */
lastSeenAt?: string | null;
/** 錯誤詳情status=error 時顯示在 tooltip / title */
errorMessage?: string | null;
size?: "sm" | "md";
/** 是否顯示 lastSeen 次要文字(卡片空間受限時可關閉) */
showLastSeen?: boolean;
className?: string;
}
/**
* components.md §10.3
* - < 60
* - < 60 X
* - < 24 X
* - 24 MM/DD HH:mm
*/
function formatRelativeTime(isoString: string, nowMs: number, t: (k: string) => string): string {
const ts = Date.parse(isoString);
if (Number.isNaN(ts)) return "";
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
if (diffSec < 60) return t("remote.lastSeen.justNow");
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
const d = new Date(ts);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${mm}/${dd} ${hh}:${mi}`;
}
export function RemoteDeviceBadge({
status,
lastSeenAt,
errorMessage,
size = "md",
showLastSeen = true,
className,
}: RemoteDeviceBadgeProps) {
const t = useT();
const labelKey = `remote.status.${status === "unknown" ? "offline" : status}`;
const label = t(labelKey);
// Icon 選擇(不只靠顏色 — design-review M2
const Icon = (() => {
switch (status) {
case "online":
return CheckCircle2;
case "reconnecting":
return Loader2;
case "error":
return AlertCircle;
case "offline":
case "unknown":
default:
return Circle;
}
})();
// Dot 色(對齊 F2 --status-* token + unknown 走 muted
const dotClass = cn(
"rounded-full shrink-0",
size === "sm" ? "size-2" : "size-2.5",
status === "online" && "bg-status-online",
status === "offline" && "bg-status-offline",
status === "reconnecting" && "bg-status-reconnecting animate-pulse",
status === "error" && "bg-destructive",
status === "unknown" && "bg-muted-foreground",
);
const iconClass = cn(
size === "sm" ? "size-3" : "size-3.5",
status === "online" && "text-status-online",
status === "offline" && "text-muted-foreground",
status === "reconnecting" && "text-status-reconnecting animate-spin",
status === "error" && "text-destructive",
status === "unknown" && "text-muted-foreground",
);
// 次要資訊(最後心跳)— React 19 lint 禁止 render 期間呼叫 Date.now()impure
// 用 state + useEffect 方式SSR 時 nowMs=0 不顯示相對時間client mount 後更新。
const [nowMs, setNowMs] = useState(0);
useEffect(() => {
queueMicrotask(() => setNowMs(Date.now()));
// 每 60s tick讓「2 分鐘前」自然演進成「3 分鐘前」
const timer = setInterval(() => setNowMs(Date.now()), 60_000);
return () => clearInterval(timer);
}, []);
const subText = (() => {
if (!showLastSeen || status === "online" || status === "unknown") return "";
if (!lastSeenAt) return t("remote.lastSeenNever");
if (nowMs === 0) return ""; // SSR / 首次 render不顯示避免 hydration mismatch
return formatRelativeTime(lastSeenAt, nowMs, t);
})();
// Tooltip / aria-description
const describe =
status === "error" && errorMessage
? `${label} · ${errorMessage}`
: subText
? `${label} · ${subText}`
: label;
return (
<div
role="status"
aria-live="polite"
aria-label={describe}
data-status={status}
title={describe}
className={cn(
"inline-flex items-center gap-1.5",
size === "sm" ? "text-xs" : "text-sm",
className,
)}
>
<span aria-hidden="true" className={dotClass} />
<Icon aria-hidden="true" className={iconClass} />
<span className="text-foreground">{label}</span>
{subText && (
<span className="text-muted-foreground text-xs">· {subText}</span>
)}
</div>
);
}

View File

@ -0,0 +1,132 @@
"use client";
/**
* ActivityTimeline
*
* `local-tool/frontend/src/components/dashboard/activity-timeline.tsx`
*
*
* - `.autoflow/03-design/components.md` §4
* - `.autoflow/03-design/flows/flow-offline-handling.md` §9
*
*
* - Activity type device_paired / device_online / tunnel_reconnected
* - local activity-store activity-store
* - i18n keyF6
* - design-review m3
* - 60s tick `suppressHydrationWarning` SSR
*/
import { useEffect, useState } from "react";
import {
AlertTriangle,
CheckCircle,
Link2,
RefreshCw,
Trash2,
Unlink,
Upload,
XCircle,
Zap,
type LucideIcon,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
import { useActivityStore, type ActivityType } from "@/stores/activity-store";
const activityIcons: Record<ActivityType, LucideIcon> = {
device_paired: Link2,
device_unpaired: Unlink,
device_online: CheckCircle,
device_offline: XCircle,
tunnel_reconnected: RefreshCw,
flash_start: Zap,
flash_complete: CheckCircle,
flash_error: XCircle,
model_upload: Upload,
model_delete: Trash2,
cluster_degraded: AlertTriangle,
};
/**
* flow-offline-handling.md §9
* 使 text-chart-* / text-destructive / text-status-* token
*/
const activityColors: Record<ActivityType, string> = {
device_paired: "text-chart-1",
device_unpaired: "text-muted-foreground",
device_online: "text-status-online",
device_offline: "text-muted-foreground",
tunnel_reconnected: "text-status-reconnecting",
flash_start: "text-chart-3",
flash_complete: "text-status-online",
flash_error: "text-destructive",
model_upload: "text-chart-1",
model_delete: "text-destructive",
cluster_degraded: "text-chart-3",
};
export function ActivityTimeline() {
const t = useT();
const activities = useActivityStore((s) => s.activities).slice(0, 10);
const [nowMs, setNowMs] = useState(0);
// 每 60s tick 一次SSR 階段 nowMs=0client mount 後才填入真實值避免 hydration mismatch
// React 19 linteffect body 同步 setState 會觸發 cascading renders改走 queueMicrotask + interval
useEffect(() => {
queueMicrotask(() => setNowMs(Date.now()));
const timer = setInterval(() => setNowMs(Date.now()), 60_000);
return () => clearInterval(timer);
}, []);
function formatTime(ts: number): string {
if (nowMs === 0) return "";
const diffMin = Math.floor((nowMs - ts) / 60_000);
if (diffMin < 1) return t("dashboard.activity.justNow");
if (diffMin < 60) return t("dashboard.activity.minutesAgo").replace("{n}", String(diffMin));
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return t("dashboard.activity.hoursAgo").replace("{n}", String(diffHour));
const diffDay = Math.floor(diffHour / 24);
return t("dashboard.activity.daysAgo").replace("{n}", String(diffDay));
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.recentActivity")}</CardTitle>
</CardHeader>
<CardContent>
{activities.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noActivity")}
</p>
) : (
<ul className="space-y-3" data-testid="activity-list">
{activities.map((activity) => {
const Icon = activityIcons[activity.type];
const color = activityColors[activity.type];
return (
<li key={activity.id} className="flex items-start gap-3">
<Icon
aria-hidden="true"
className={`mt-0.5 size-4 shrink-0 ${color}`}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{activity.message}</p>
<p
className="text-muted-foreground text-xs"
suppressHydrationWarning
>
{formatTime(activity.timestamp)}
</p>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,86 @@
"use client";
/**
* ConnectedDevicesList Dashboard /
*
* `local-tool/frontend/src/components/dashboard/connected-devices-list.tsx`
*
*
* - `.autoflow/03-design/pages.md` §1.2ConnectedDevicesList RemoteDeviceBadge
* - `.autoflow/03-design/flows/flow-offline-handling.md` §4.3
*
*
* - local-tool `status=connected` remoteStatus=online
* - `RemoteDeviceBadge`
* - `/workspace/[deviceId]` online `/devices/[id]` offline
*/
import Link from "next/link";
import { Cable } from "lucide-react";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
export function ConnectedDevicesList() {
const t = useT();
// 雲端版語意列「線上」裝置remoteStatus=online不是 USB 連接
const devices = useDeviceStore((s) =>
s.devices.filter((d) => d.remoteStatus === "online"),
);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.connectedDevices")}</CardTitle>
</CardHeader>
<CardContent>
{devices.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noConnectedDevices")}
</p>
) : (
<ul className="space-y-3" data-testid="connected-devices-list">
{devices.map((device) => {
const displayName = device.alias || device.name;
return (
<li
key={device.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex min-w-0 items-center gap-3">
<Cable
aria-hidden="true"
className="text-status-online size-4 shrink-0"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{displayName}</p>
<p className="text-muted-foreground truncate text-xs">
{device.type}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
showLastSeen={false}
/>
<Link href={`/devices/${device.id}`}>
<Button size="sm" variant="ghost">
{t("common.view")}
</Button>
</Link>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,27 @@
/**
* StatCard F6
*/
import { render, screen } from "@testing-library/react";
import { Boxes } from "lucide-react";
import { describe, expect, it } from "vitest";
import { StatCard } from "./stat-card";
describe("<StatCard />", () => {
it("能 render title + value", () => {
render(<StatCard title="模型" value={42} />);
expect(screen.getByText("模型")).toBeInTheDocument();
expect(screen.getByTestId("stat-value")).toHaveTextContent("42");
});
it("有 href 時整張卡片會包成連結aria-label 包含 title + value", () => {
render(<StatCard title="裝置" value={3} icon={Boxes} href="/devices" />);
const link = screen.getByRole("link", { name: /裝置.*3/ });
expect(link).toHaveAttribute("href", "/devices");
});
it("subtitle 有值時會顯示於 value 下方", () => {
render(<StatCard title="線上" value="2" subtitle="最近 5 分鐘" />);
expect(screen.getByText("最近 5 分鐘")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,69 @@
"use client";
/**
* StatCard
*
* `local-tool/frontend/src/components/dashboard/stat-card.tsx`沿F6
*
*
* - `href` prop StatCard pages.md §1.4
* - `aria-label` Screen Reader
* - iconColor Design Tokenchart-* Tailwind design-review m1
*/
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon?: LucideIcon;
/** Tailwind text color class建議用 text-chart-1 ~ text-chart-5 */
iconColor?: string;
/** 點擊卡片跳到這個路由(例如 `/devices` */
href?: string;
}
export function StatCard({ title, value, subtitle, icon: Icon, iconColor, href }: StatCardProps) {
const content = (
<Card
className={cn(
"h-full",
href && "hover:bg-accent focus-visible:ring-ring cursor-pointer transition-colors focus-visible:ring-2 focus-visible:outline-none",
)}
>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{title}
</CardTitle>
{Icon && (
<Icon
aria-hidden="true"
className={cn("size-4", iconColor ?? "text-muted-foreground")}
/>
)}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold" data-testid="stat-value">
{value}
</div>
{subtitle && (
<p className="text-muted-foreground mt-1 text-sm">{subtitle}</p>
)}
</CardContent>
</Card>
);
if (href) {
return (
<Link href={href} aria-label={`${title}: ${value}`} className="block">
{content}
</Link>
);
}
return content;
}

View File

@ -0,0 +1,96 @@
"use client";
/**
* DeviceCard
*
* `local-tool/frontend/src/components/devices/device-card.tsx`
*
*
* - `.autoflow/03-design/components.md` §5Devices
* - `.autoflow/03-design/pages.md` §5
* - `.autoflow/03-design/flows/flow-offline-handling.md` §4.1
*
*
* - `RemoteDeviceBadge`
* - remoteStatus != online opacity-75 + disabled
* - remoteStatus=online flow-offline-handling §4.1
* - local-tool connect/disconnect `/devices/[id]`
* - Link `/devices/[id]`design-review M3 hover
*/
import Link from "next/link";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DeviceSummary } from "@/stores/device-store";
interface DeviceCardProps {
device: DeviceSummary;
}
export function DeviceCard({ device }: DeviceCardProps) {
const t = useT();
const displayName = device.alias || device.name;
const isOnline = device.remoteStatus === "online";
return (
<Card
data-testid="device-card"
data-remote-status={device.remoteStatus}
className={cn(
"transition-colors",
// 離線裝置 opacity-75flow-offline-handling §4.1
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="truncate text-base">{displayName}</CardTitle>
{device.alias && (
<p className="text-muted-foreground truncate text-xs">{device.name}</p>
)}
</div>
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
/>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-muted-foreground">{t("devices.type")}</p>
<p className="font-medium">{device.type || "—"}</p>
</div>
<div>
<p className="text-muted-foreground">{t("devices.firmware")}</p>
<p className="font-medium">{device.firmwareVersion || t("common.na")}</p>
</div>
{device.flashedModel && (
<div className="col-span-2">
<p className="text-muted-foreground">{t("devices.flashedModel")}</p>
<p className="truncate font-medium">{device.flashedModel}</p>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Link href={`/devices/${device.id}`}>
<Button size="sm" variant="outline">
{t("common.manage")}
</Button>
</Link>
{isOnline && device.flashedModel && (
<Link href={`/workspace/${device.id}`}>
<Button size="sm">{t("devices.openWorkspace")}</Button>
</Link>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,102 @@
"use client";
/**
* DeviceList + + skeleton
*
* `local-tool/frontend/src/components/devices/device-list.tsx`
*
*
* - `.autoflow/03-design/pages.md` §5.3
*
*
* - `/devices/pair`F7 Pairing scan
* - online reconnecting unknown offline error
*/
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Link2 } from "lucide-react";
import { DeviceCard } from "@/components/devices/device-card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useT } from "@/lib/i18n/context";
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
interface DeviceListProps {
devices: DeviceSummary[];
loading?: boolean;
}
const STATUS_ORDER: Record<RemoteStatus, number> = {
online: 0,
reconnecting: 1,
unknown: 2,
offline: 3,
error: 4,
};
export function DeviceList({ devices, loading }: DeviceListProps) {
const t = useT();
const router = useRouter();
if (loading) {
return (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
data-testid="device-list-skeleton"
>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-48 rounded-lg" />
))}
</div>
);
}
if (devices.length === 0) {
return (
<EmptyState
icon={Link2}
title={t("devices.empty.title")}
description={t("devices.empty.description")}
action={{
label: t("devices.empty.action"),
onClick: () => router.push("/devices/pair"),
}}
secondaryAction={{
label: t("devices.empty.secondaryAction"),
onClick: () => {
// F7 會補 `/help/pairing`Phase 0 先導到 pair 頁讓使用者至少能走完流程
router.push("/devices/pair");
},
}}
/>
);
}
const sorted = [...devices].sort(
(a, b) => STATUS_ORDER[a.remoteStatus] - STATUS_ORDER[b.remoteStatus],
);
return (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
data-testid="device-list"
>
{sorted.map((device) => (
<DeviceCard key={device.id} device={device} />
))}
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
<Link
href="/devices/pair"
data-testid="pair-new-device-cta"
className="border-border hover:border-primary hover:bg-accent focus-visible:ring-ring flex min-h-[12rem] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Link2 aria-hidden="true" className="text-muted-foreground size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("devices.addMore")}
</span>
</Link>
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
/**
* AppShell PrototypeBanner + Sidebar + Header + main
*
*
*
* PrototypeBanner (h-9) sticky top-0z-30
*
* Header (h-14) sticky
* Sidebar
* (w-60)
* <main>
* {children}
*
*
*
*
* - 使 Flex verticalbanner + flex row row Sidebar + column
* - PrototypeBanner 使 `sticky top-0 z-30`
* F5+ NetworkErrorBanner z-40
* - `min-h-dvh`layout.tsx body flex-1 main
* - MobileSidebar Sidebar `hidden sm:flex` main 滿
*/
import { Header } from "./header";
import { PrototypeBanner } from "./prototype-banner";
import { Sidebar } from "./sidebar";
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<div className="flex min-h-dvh flex-col">
{/* 全域雛形提示 — 常駐於最上方 */}
<div className="sticky top-0 z-30">
<PrototypeBanner />
</div>
{/* 主體:左 Sidebar + 右側垂直 columnHeader + main */}
<div className="flex flex-1 min-h-0">
<Sidebar />
{/* 右側欄 — flex-1 吃滿剩餘寬度 */}
<div className="flex flex-1 flex-col min-w-0">
<Header />
<main
// min-w-0 避免內部過長內容撐爆 flex row
// main 本身可捲動Sidebar 保持固定
className="flex-1 min-w-0 overflow-y-auto"
>
{children}
</main>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,399 @@
"use client";
/**
* Header PrototypeBanner
*
*
* - .autoflow/03-design/components.md §3Layout
* - .autoflow/03-design/flows/flow-offline-handling.md §2 tunnel
*
*
* [Logo / Mobile Desktop Sidebar]
* [ Mobile Desktop ]
* [ ]
* [ Tunnel badge + tooltip ]
* [ Locale ]
* [ Theme ]
* [ UserMenu ]
*
* F5
* - `useAuthStore()` DEMO_USER
* - `useTunnelStatus()` DEMO_TUNNEL
* - UserMenu / Welcome / fallbackF7 /login
* - usePathname() Review F4 Minor #3 F6
*
* OF2 Phase 0.6 OIDC BFF
* - auth-store `token` `isAuthenticated` `user !== null`
* - User shape `{id, email, displayName}` `{id, email, name}` OIDC claims
* - `user.name`name fallback `user.email`
*/
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { Globe, LogOut, Moon, Sun, UserCog } from "lucide-react";
import {
Avatar,
AvatarFallback,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTunnelStatus } from "@/hooks/use-tunnel-status";
import { SUPPORTED_LOCALES } from "@/lib/i18n/types";
import { useLocale, useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import type { TunnelStatus } from "@/stores/session-store";
/** 未登入 / 尚未 hydrate 時顯示的 fallback。email 格式讓 UserMenu fallback initial 有字可取。 */
const GUEST_FALLBACK = {
email: "guest@visionA.local",
name: "訪客",
} as const;
export function Header() {
const t = useT();
// F5tunnel status 改讀 session-store初始為 "unknown",等 WS / polling 更新)
const tunnel = useTunnelStatus();
return (
<header
data-slot="header"
// h-14 與 Sidebar 品牌區等高,形成水平齊整感
className={cn(
"flex h-14 shrink-0 items-center gap-3 border-b px-4 sm:px-6",
"bg-card text-card-foreground",
)}
>
{/* Mobile 才顯示 Logo+產品名(因為 Sidebar 在 mobile 隱藏) */}
<Link
href="/"
className="flex items-center gap-2 font-semibold sm:hidden"
aria-label={t("app.title")}
>
<span
aria-hidden="true"
className="bg-primary text-primary-foreground grid size-7 place-items-center rounded-md text-xs font-bold"
>
vA
</span>
<span className="text-sm">{t("app.title")}</span>
</Link>
{/* 麵包屑Desktop 才顯示) */}
<Breadcrumb />
{/* 延展空白把右側工具推到底 */}
<div className="flex-1" />
{/* 右側操作列 */}
<div className="flex items-center gap-1 sm:gap-2">
<TunnelStatusIndicator status={tunnel.status} rtt={tunnel.rtt} />
<LocaleToggle />
<ThemeToggle />
<UserMenu />
</div>
</header>
);
}
/* -------------------------------------------------------------------------- */
/* 麵包屑 — 依 pathname 自動產生 */
/* -------------------------------------------------------------------------- */
function Breadcrumb() {
const pathname = usePathname();
const t = useT();
// 把路徑拆成 segments。`/` → []`/devices/pair` → ['devices', 'pair']
const segments = pathname.split("/").filter(Boolean);
return (
<nav
aria-label="breadcrumb"
// Mobile 隱藏空間太小sm 以上顯示
className="text-muted-foreground hidden items-center gap-1.5 text-sm sm:flex"
>
<Link
href="/"
className="hover:text-foreground rounded-sm px-1 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
{t("header.breadcrumb.home")}
</Link>
{segments.map((seg, idx) => {
const href = "/" + segments.slice(0, idx + 1).join("/");
const isLast = idx === segments.length - 1;
return (
<span key={href} className="flex items-center gap-1.5">
<span aria-hidden="true">/</span>
{isLast ? (
<span className="text-foreground font-medium capitalize">
{seg}
</span>
) : (
<Link
href={href}
className="hover:text-foreground rounded-sm px-1 capitalize outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
{seg}
</Link>
)}
</span>
);
})}
</nav>
);
}
/* -------------------------------------------------------------------------- */
/* Tunnel 狀態燈(裝置連線狀態色 — F2 已定義的 --status-* tokens */
/* -------------------------------------------------------------------------- */
interface TunnelStatusIndicatorProps {
status: TunnelStatus;
rtt: number | null;
}
function TunnelStatusIndicator({ status, rtt }: TunnelStatusIndicatorProps) {
const t = useT();
// F5 Review Minor #1F6 修):
// `unknown` 仍以「離線」文字顯示(對使用者來說「還沒確認連上」≈ 離線),但:
// - `data-status` 屬性保留為 `unknown`E2E / QA 能區分「未確認 vs 已知 offline」
// - dot 色走 `bg-muted-foreground`(灰),視覺上弱於真實 offline 的紅/灰 token
// 這樣初始 WS 尚未連上時UI 呈現為中性灰,而不是誤導性的「已確認離線」紅色。
const textStatus: Exclude<TunnelStatus, "unknown"> =
status === "unknown" ? "offline" : status;
const statusLabel = t(`tunnel.status.${textStatus}`);
// 對齊 globals.css 的 --status-* tokenbg-status-online / offline / reconnecting
// `unknown` 特殊處理:用 `bg-muted-foreground` 代表「未確認」
const dotClass = cn(
"size-2 rounded-full shrink-0",
status === "unknown" && "bg-muted-foreground",
status === "online" && "bg-status-online",
status === "offline" && "bg-status-offline",
status === "reconnecting" && "bg-status-reconnecting animate-pulse",
);
// Tooltip 顯示 RTT若有否則只顯示狀態文字
const tooltipText =
typeof rtt === "number" && status === "online"
? `${statusLabel} · ${t("tunnel.rttLabel").replace("{rtt}", String(rtt))}`
: statusLabel;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
role="status"
aria-live="polite"
aria-label={tooltipText}
data-testid="tunnel-status"
// 保留原始 status含 `unknown`),方便 E2E 斷言「未確認 vs 已知 offline」
data-status={status}
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium",
"text-muted-foreground hover:text-foreground hover:bg-accent",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span aria-hidden="true" className={dotClass} />
{/* Mobile 只顯示燈sm 以上顯示文字 */}
<span className="hidden sm:inline">{statusLabel}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
</Tooltip>
);
}
/* -------------------------------------------------------------------------- */
/* Locale 切換 — 雛形以單按鈕 cyclezh-Hant ↔ en */
/* -------------------------------------------------------------------------- */
function LocaleToggle() {
const { locale, setLocale } = useLocale();
const t = useT();
function handleToggle() {
// 支援未來擴充到 2 種以上 locale找出目前位置的下一個
const idx = SUPPORTED_LOCALES.indexOf(locale);
const next = SUPPORTED_LOCALES[(idx + 1) % SUPPORTED_LOCALES.length];
setLocale(next);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("header.toggleLocale")}
onClick={handleToggle}
data-testid="locale-toggle"
>
<Globe className="size-4" />
{/* 螢幕閱讀器會讀 aria-labelsighted 使用者從 tooltip 看到當前 locale */}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("header.toggleLocale")} · {locale}
</TooltipContent>
</Tooltip>
);
}
/* -------------------------------------------------------------------------- */
/* Theme 切換 — 雛形以單按鈕 cyclelight → dark → system → light */
/* -------------------------------------------------------------------------- */
function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const t = useT();
function handleToggle() {
// 以 resolvedTheme 決定目前「視覺上」是深還是淺,切換至相反色
// 使用者若想回到 system可透過未來的 Settings 頁
if (resolvedTheme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
}
// 讓 icon 依當前主題切換SSR hydration 保險:以 resolvedTheme 為準,
// 但避免 SSR/CSR 不一致,首次 render 先顯示 Sun等 client mount 才切換)
const isDark = theme === "dark" || resolvedTheme === "dark";
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("header.toggleTheme")}
onClick={handleToggle}
data-testid="theme-toggle"
>
{isDark ? (
<Moon className="size-4" />
) : (
<Sun className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("header.toggleTheme")}</TooltipContent>
</Tooltip>
);
}
/* -------------------------------------------------------------------------- */
/* UserMenu — Avatar + DropdownMenu */
/* -------------------------------------------------------------------------- */
function UserMenu() {
const t = useT();
const router = useRouter();
// OF2從 auth-store 讀未登入user 為 null時走 fallback
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.user !== null);
const logout = useAuthStore((s) => s.logout);
const displayUser = user ?? GUEST_FALLBACK;
// OIDC `name` / `email` claim 都可能 undefinedMC id_token 沒帶backend `omitempty`)。
// 依序 fallbackname → email → user.idOIDC sub→ 「使用者」字面量
// ⚠️ 這裡若不 fallback 到非空字串,下一行的 `.charAt(0)` 會 throw → React tree crash。
const displayName =
(displayUser.name && displayUser.name.length > 0 ? displayUser.name : undefined) ??
(displayUser.email && displayUser.email.length > 0 ? displayUser.email : undefined) ??
("id" in displayUser && displayUser.id.length > 0 ? displayUser.id : undefined) ??
t("header.userMenu.fallbackName");
// 取顯示名稱首字母作為 Avatar fallback大寫— 訪客為 "訪"
// displayName 至少是 t("header.userMenu.fallbackName") 非空字串charAt 安全
const initial = displayName.charAt(0).toUpperCase();
async function handleLogout() {
await logout();
// 登出後導 /loginF7 才真正實作此路由;在那之前會 404 但 logout 本身已生效)
router.push("/login");
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("header.userMenu.open")}
data-testid="user-menu-trigger"
className="rounded-full"
>
<Avatar className="size-7">
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuLabel className="flex flex-col gap-0.5">
<span className="font-medium">{displayName}</span>
{/* 副標email 存在且與主名稱不同時才顯示,避免「主/副」兩行重複 */}
{displayUser.email && displayUser.email !== displayName ? (
<span className="text-muted-foreground text-xs font-normal">
{displayUser.email}
</span>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isAuthenticated ? (
<>
<DropdownMenuItem asChild>
{/* 已登入導到帳號設定F7 建此頁) */}
<Link href="/settings">
<UserCog className="size-4" />
{t("header.userMenu.profile")}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={(e) => {
// 阻止 DropdownMenu 的預設 close-before-handler 行為,我們要 await logout
e.preventDefault();
void handleLogout();
}}
data-testid="user-menu-logout"
>
<LogOut className="size-4" />
{t("header.userMenu.logout")}
</DropdownMenuItem>
</>
) : (
<>
{/* 未登入:顯示登入入口 */}
<DropdownMenuItem asChild data-testid="user-menu-login">
<Link href="/login">
<UserCog className="size-4" />
{t("auth.action.signIn")}
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,88 @@
/**
* PrototypeBanner
*
* flow-auth.md §1.2 / §1.3
* 1. render i18n
* 2. role="status" + aria-label
* 3. **** DOM close /
* 4.
*/
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LocaleProvider, useLocale } from "@/lib/i18n/context";
import { PrototypeBanner } from "./prototype-banner";
function LocaleSwitcher() {
const { setLocale } = useLocale();
return (
<button type="button" onClick={() => setLocale("en")} data-testid="to-en">
en
</button>
);
}
describe("<PrototypeBanner />", () => {
it("render 為 role='status' 並帶 aria-labelSR 可辨識)", () => {
render(
<LocaleProvider>
<PrototypeBanner />
</LocaleProvider>,
);
const banner = screen.getByRole("status");
expect(banner).toBeInTheDocument();
expect(banner).toHaveAttribute("aria-label", "雛形版本提示");
});
it("預設顯示繁中長 + 短兩段文案(長版顯示於 sm+,短版顯示於 mobile", () => {
render(
<LocaleProvider>
<PrototypeBanner />
</LocaleProvider>,
);
// 長版文案Phase 0.7OIDC 已接 Innovedus 帳號中心後文案調整)
expect(
screen.getByText(
"🚧 雛形版本 · 已接 Innovedus 帳號中心,但部分功能仍為 UI 示意;資料為示範資料",
),
).toBeInTheDocument();
// 短版文案RWD mobile
expect(screen.getByText("🚧 雛形版本demo")).toBeInTheDocument();
});
it("不可關閉 — DOM 中不存在任何「關閉 / Dismiss」按鈕", () => {
render(
<LocaleProvider>
<PrototypeBanner />
</LocaleProvider>,
);
// 沒有任何 buttonBanner 是純顯示元素)
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("切換 locale 為 en 後文案跟著改", () => {
render(
<LocaleProvider>
<PrototypeBanner />
<LocaleSwitcher />
</LocaleProvider>,
);
act(() => {
screen.getByTestId("to-en").click();
});
expect(
screen.getByText(
"🚧 Prototype build · Member Center login enabled, but some flows are UI mockups; sample data only.",
),
).toBeInTheDocument();
// aria-label 也應切到 en 版
expect(screen.getByRole("status")).toHaveAttribute(
"aria-label",
"Prototype notice",
);
});
});

View File

@ -0,0 +1,51 @@
"use client";
/**
* PrototypeBanner
*
* .autoflow/03-design/flows/flow-auth.md §1.2 / §1.3
*
*
* - AppShell sticky top-0
* - design spec amber destructive
* - 使
* - role="status" + aria-label
* - `banner.prototype.short`
* CSS `hidden sm:inline` / `sm:hidden` DOM
* SR
*/
import { Construction } from "lucide-react";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
interface PrototypeBannerProps {
className?: string;
}
export function PrototypeBanner({ className }: PrototypeBannerProps) {
const t = useT();
return (
<div
role="status"
aria-label={t("banner.prototype.ariaLabel")}
data-slot="prototype-banner"
className={cn(
// 顏色amber 系提醒(非 destructiveLight / Dark 皆兼顧
"bg-amber-100 text-amber-900 border-b border-amber-300",
"dark:bg-amber-900/40 dark:text-amber-100 dark:border-amber-700",
// 高度:固定 h-9約 36px後續 Header 在其下疊加
"flex h-9 items-center justify-center gap-2 px-4",
"text-xs font-medium",
className,
)}
>
<Construction className="size-3.5 shrink-0" aria-hidden="true" />
{/* sm 以上顯示完整文案;手機顯示縮短版 — 保持整行不換行避免破版 */}
<span className="hidden truncate sm:inline">{t("banner.prototype")}</span>
<span className="truncate sm:hidden">{t("banner.prototype.short")}</span>
</div>
);
}

View File

@ -0,0 +1,108 @@
/**
* Sidebar
*
*
* 1. isNavActive() edge cases startsWith
* 2. NavItem aria-current="page"
* 3. NavItem aria-current
* 4. pages.md 6
*
* mock next/navigation usePathname jsdom
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { Sidebar, isNavActive } from "./sidebar";
// mock usePathname — 每個測試自行控制回傳值
vi.mock("next/navigation", () => ({
usePathname: vi.fn(),
}));
// 取得可控制的 mock 引用
import { usePathname } from "next/navigation";
const usePathnameMock = vi.mocked(usePathname);
describe("isNavActive", () => {
it("根路徑 '/' 嚴格比對(不應被任何子路徑啟用)", () => {
expect(isNavActive("/", "/")).toBe(true);
expect(isNavActive("/", "/devices")).toBe(false);
expect(isNavActive("/", "/devices/pair")).toBe(false);
});
it("非根路徑:完全相等或子路徑都 active", () => {
expect(isNavActive("/devices", "/devices")).toBe(true);
expect(isNavActive("/devices", "/devices/pair")).toBe(true);
expect(isNavActive("/devices", "/devices/123")).toBe(true);
});
it("prefix 相同但非子路徑不應 active避免 /device 啟用 /devices", () => {
// '/dev' 不應啟用 '/devices' 項目
expect(isNavActive("/devices", "/dev")).toBe(false);
// '/devicesxyz' 也不是 /devices 的子路徑(沒有 / 接續)
expect(isNavActive("/devices", "/devicesxyz")).toBe(false);
});
it("不相關的路徑應為 false", () => {
expect(isNavActive("/models", "/devices")).toBe(false);
expect(isNavActive("/settings", "/")).toBe(false);
});
});
describe("<Sidebar />", () => {
it("在 /devices/pair 時Devices 項目被標記為 aria-current=page", () => {
usePathnameMock.mockReturnValue("/devices/pair");
render(
<LocaleProvider>
<Sidebar />
</LocaleProvider>,
);
const devicesLink = screen.getByRole("link", { name: /裝置/ });
expect(devicesLink).toHaveAttribute("aria-current", "page");
// Dashboard 不該 active
const dashboardLink = screen.getByRole("link", { name: /儀表板/ });
expect(dashboardLink).not.toHaveAttribute("aria-current");
});
it("在根路徑 '/' 時僅 Dashboard active", () => {
usePathnameMock.mockReturnValue("/");
render(
<LocaleProvider>
<Sidebar />
</LocaleProvider>,
);
const dashboardLink = screen.getByRole("link", { name: /儀表板/ });
expect(dashboardLink).toHaveAttribute("aria-current", "page");
// 任一非根項目都不 active
expect(screen.getByRole("link", { name: /裝置/ })).not.toHaveAttribute(
"aria-current",
);
});
it("包含 pages.md 總覽規定的 6 個主導航項目", () => {
usePathnameMock.mockReturnValue("/");
render(
<LocaleProvider>
<Sidebar />
</LocaleProvider>,
);
// nav 內部的 link 數 = 6不含品牌 logo link
const nav = screen.getByRole("navigation");
const links = nav.querySelectorAll("a");
expect(links).toHaveLength(6);
// 抽樣:確保 clusters 已納入(雲端版新增)
expect(screen.getByRole("link", { name: /叢集/ })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,157 @@
"use client";
/**
* Sidebar
*
*
* - .autoflow/03-design/components.md §3.1 Sidebar
* - .autoflow/03-design/pages.md
*
*
* - 使 Lucide Icon design-review.md C1 local-tool
* - usePathname() '/' startsWith
* - active state `bg-sidebar-accent text-sidebar-accent-foreground`
* sidebar-* Design Tokensglobals.css Light/Dark
* - Desktop w-60Mobile AppShell F drawer
* - package.json version
*
*
* - Mobile drawer / SheetF6
* - UserMenu F4 Header Sidebar
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Boxes,
Cable,
LayoutDashboard,
Network,
Play,
Settings,
type LucideIcon,
} from "lucide-react";
import { useT, type TranslateFn } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
/** i18n key由 t() 展開 */
labelKey: string;
icon: LucideIcon;
}
/**
* pages.md components.md §3.1 icon
* test assertion
*/
const NAV_ITEMS: readonly NavItem[] = [
{ href: "/", labelKey: "nav.dashboard", icon: LayoutDashboard },
{ href: "/devices", labelKey: "nav.devices", icon: Cable },
{ href: "/models", labelKey: "nav.models", icon: Boxes },
{ href: "/workspace", labelKey: "nav.workspace", icon: Play },
{ href: "/clusters", labelKey: "nav.clusters", icon: Network },
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
] as const;
/**
* active
* test 使 active
*/
export function isNavActive(itemHref: string, pathname: string): boolean {
if (itemHref === "/") return pathname === "/";
// startsWith 比對,使得 /devices/pair 也能讓 /devices 維持 active 狀態
return pathname === itemHref || pathname.startsWith(`${itemHref}/`);
}
interface SidebarProps {
className?: string;
}
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname();
const t = useT();
return (
<aside
data-slot="sidebar"
// Mobile 隱藏Tablet (sm) 以上顯示
// 背景 / 邊框用 sidebar-* tokens天生支援 Dark Mode
className={cn(
"hidden h-full w-60 flex-col border-r sm:flex",
"bg-sidebar text-sidebar-foreground",
className,
)}
>
{/* 品牌區 — 與 Header 等高 (h-14),視覺水平對齊 */}
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<Link
href="/"
className="flex items-center gap-2 font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={t("app.title")}
>
{/* Logo 暫以純色方塊占位 — 後續可替換為 <Image /> 或 SVG */}
<span
aria-hidden="true"
className="bg-sidebar-primary text-sidebar-primary-foreground grid size-8 place-items-center rounded-md text-xs font-bold"
>
vA
</span>
<span className="truncate text-base">{t("app.title")}</span>
</Link>
</div>
{/* 導航清單 */}
<nav
aria-label={t("app.title")}
className="flex-1 space-y-1 overflow-y-auto p-3"
>
{NAV_ITEMS.map((item) => (
<NavItemLink
key={item.href}
item={item}
active={isNavActive(item.href, pathname)}
t={t}
/>
))}
</nav>
{/* F4 Phase 1 package.json
Phase 0.7 stage deployment fix .autoflow/05-implementation/phase-0.7-frontend-fix.md
phase "Phase 0" "Phase 0.7" OIDC Member Center */}
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
v0.1.0 · Phase 0.7
</div>
</aside>
);
}
interface NavItemLinkProps {
item: NavItem;
active: boolean;
t: TranslateFn;
}
function NavItemLink({ item, active, t }: NavItemLinkProps) {
const Icon = item.icon;
const label = t(item.labelKey);
return (
<Link
href={item.href}
data-active={active || undefined}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
</Link>
);
}

View File

@ -0,0 +1,100 @@
"use client";
/**
* ModelCard
*
* `local-tool/frontend/src/components/models/model-card.tsx`
*
*
* - api-spec §4 ModeltargetChip / fileSize / source / status / createdAt
* local-tool accuracy / fps / supportedHardware preset metadata
* - Badge flow-model-upload §5.4uploading / scanning / ready / rejected
* - Checkbox comparison
*/
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ModelStatus, ModelSummary } from "@/stores/model-store";
interface ModelCardProps {
model: ModelSummary;
}
/** 狀態 Badge variant 映射(對齊 flow-model-upload §5.4 */
const STATUS_VARIANT: Record<ModelStatus, { variant: "default" | "secondary" | "destructive" | "outline"; key: string }> = {
uploading: { variant: "secondary", key: "models.status.uploading" },
scanning: { variant: "secondary", key: "models.status.scanning" },
ready: { variant: "default", key: "models.status.ready" },
rejected: { variant: "destructive", key: "models.status.rejected" },
};
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function ModelCard({ model }: ModelCardProps) {
const t = useT();
const statusMeta = STATUS_VARIANT[model.status];
return (
<Link href={`/models/${model.id}`} data-testid="model-card">
<Card
className={cn(
"hover:bg-accent/40 h-full cursor-pointer transition-shadow hover:shadow-md",
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-tight">{model.name}</CardTitle>
<Badge variant={statusMeta.variant} className="shrink-0 text-xs">
{t(statusMeta.key)}
</Badge>
</div>
<div className="flex flex-wrap gap-1">
<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")}
</Badge>
)}
{model.category && (
<Badge variant="outline" className="text-xs">
{model.category}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-muted-foreground">{t("models.size")}</p>
<p className="font-medium">{formatFileSize(model.fileSize)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("models.createdAt")}</p>
<p className="font-medium">
{model.createdAt
? new Date(model.createdAt).toLocaleDateString()
: "—"}
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@ -0,0 +1,81 @@
"use client";
/**
* ModelFilters
*
* api-spec §4 targetChip + source
* Phase 1 design-review Search
*/
import { Filter } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useT } from "@/lib/i18n/context";
import type { ModelSource, TargetChip } from "@/stores/model-store";
export interface ModelFilterValue {
targetChip: TargetChip | "all";
source: ModelSource | "all";
}
interface ModelFiltersProps {
value: ModelFilterValue;
onChange: (v: ModelFilterValue) => void;
}
export function ModelFilters({ value, onChange }: ModelFiltersProps) {
const t = useT();
return (
<div
className="flex flex-wrap items-center gap-2"
data-testid="model-filters"
role="group"
aria-label={t("models.filters.label")}
>
<Filter
aria-hidden="true"
className="text-muted-foreground size-4"
/>
<Select
value={value.targetChip}
onValueChange={(v) =>
onChange({ ...value, targetChip: v as ModelFilterValue["targetChip"] })
}
>
<SelectTrigger className="h-9 w-40" aria-label={t("models.filters.hardware")}>
<SelectValue placeholder={t("models.filters.hardware")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
<SelectItem value="kl520">KL520</SelectItem>
<SelectItem value="kl720">KL720</SelectItem>
<SelectItem value="kl630">KL630</SelectItem>
<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

@ -0,0 +1,72 @@
"use client";
/**
* ModelGrid + skeleton +
*
* `local-tool/frontend/src/components/models/model-grid.tsx`
*
*
* - comparisonIds / compareMode feature flag
* - CTA
*/
import { Boxes } from "lucide-react";
import { ModelCard } from "@/components/models/model-card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useT } from "@/lib/i18n/context";
import type { ModelSummary } from "@/stores/model-store";
interface ModelGridProps {
models: ModelSummary[];
loading?: boolean;
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog */
onUploadClick?: () => void;
}
export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
const t = useT();
if (loading) {
return (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
data-testid="model-grid-skeleton"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-56 rounded-lg" />
))}
</div>
);
}
if (models.length === 0) {
return (
<EmptyState
icon={Boxes}
title={t("models.empty.title")}
description={t("models.empty.description")}
action={
onUploadClick
? {
label: t("models.empty.action"),
onClick: onUploadClick,
}
: undefined
}
/>
);
}
return (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
data-testid="model-grid"
>
{models.map((model) => (
<ModelCard key={model.id} model={model} />
))}
</div>
);
}

View File

@ -0,0 +1,324 @@
"use client";
/**
* ModelUploadDialog .nef Dialog
*
*
* - `.autoflow/03-design/flows/flow-model-upload.md`UX
* - `.autoflow/04-architecture/api/api-spec.md` §4 API
*
* API api-spec.md flow `/upload-url` / `/confirm`
* 1. POST /api/models/init { modelId, uploadUrl, uploadExpiresAt }
* 2. PUT {uploadUrl} storage presigned query string
* 3. POST /api/models/:id/finalize checksum + status ready
*
* F6
* - UI
* - `.nef` 100 MB
* - XHR progress `api.upload`
* -
*
* F6 flow Phase 0
* - drop zone
* - / ETA
* - URL
* - onbeforeunload
*
* /
* - 使name 使 trimsize / type
* - checksum size+name hashPhase 1 SHA-256 Web Crypto API
*/
import { useRef, useState } from "react";
import { Loader2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/lib/api";
import { useT } from "@/lib/i18n/context";
import { useModelStore, type TargetChip } from "@/stores/model-store";
import { toast } from "sonner";
/** 100 MBflow-model-upload §2 */
const MAX_SIZE_BYTES = 100 * 1024 * 1024;
type Phase = "select" | "uploading" | "success" | "error";
/**
* checksumPhase 0 滿 init API
* Phase 1 Web Crypto SHA-256
*
* TODO(Phase 1)
* ```ts
* const buf = await file.arrayBuffer();
* const hash = await crypto.subtle.digest("SHA-256", buf);
* return `sha256:${Array.from(new Uint8Array(hash))
* .map((b) => b.toString(16).padStart(2, "0"))
* .join("")}`;
* ```
*
* `placeholder:{size}:{nameLen}`
* Phase 1
*/
function placeholderChecksum(file: File): string {
return `placeholder:${file.size}:${file.name.length}`;
}
export function ModelUploadDialog() {
const t = useT();
const [open, setOpen] = useState(false);
const [phase, setPhase] = useState<Phase>("select");
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [targetChip, setTargetChip] = useState<TargetChip>("kl520");
const [progress, setProgress] = useState(0);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const initUpload = useModelStore((s) => s.initUpload);
const finalizeUpload = useModelStore((s) => s.finalizeUpload);
function resetAll() {
setPhase("select");
setFile(null);
setName("");
setTargetChip("kl520");
setProgress(0);
setErrorMessage(null);
abortRef.current = null;
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
// 驗副檔名
if (!f.name.toLowerCase().endsWith(".nef")) {
toast.error(t("models.upload.error.invalidType").replace("{type}", f.name.split(".").pop() ?? ""));
e.target.value = "";
return;
}
// 驗大小
if (f.size > MAX_SIZE_BYTES) {
toast.error(
t("models.upload.error.tooLarge").replace("{size}", `${(f.size / 1024 / 1024).toFixed(1)} MB`),
);
e.target.value = "";
return;
}
setFile(f);
// 預填名稱(去副檔名)
setName(f.name.replace(/\.nef$/i, ""));
}
async function handleStart() {
if (!file) return;
const trimmedName = name.trim();
if (!trimmedName) {
toast.error(t("models.upload.error.requiredField").replace("{field}", t("models.upload.field.name")));
return;
}
setPhase("uploading");
setProgress(0);
setErrorMessage(null);
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
// Step 1: init
const init = await initUpload({
name: trimmedName,
fileSize: file.size,
checksum: placeholderChecksum(file),
targetChip,
});
// Step 2: 直送 presigned URL
// flow-model-upload §7.2 要求 `Content-Type: application/octet-stream`
const { etag } = await api.upload(init.uploadUrl, file, {
method: "PUT",
absolute: true,
headers: { "Content-Type": "application/octet-stream" },
onProgress: (p) => setProgress(p.percent),
signal: ctrl.signal,
});
// Step 3: finalize
await finalizeUpload(init.modelId, etag);
setPhase("success");
toast.success(
t("models.upload.toast.uploaded").replace("{name}", trimmedName),
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setErrorMessage(message);
setPhase("error");
}
}
function handleCancelUpload() {
abortRef.current?.abort();
}
function handleOpenChange(next: boolean) {
// 上傳中不允許 ESC / outside click 關閉flow-model-upload §4.3
if (phase === "uploading" && !next) return;
setOpen(next);
if (!next) {
// 關閉時 reset延遲一幀避免 dialog 收起動畫期間 UI 閃爍)
setTimeout(resetAll, 150);
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button data-testid="model-upload-trigger">
<Upload aria-hidden="true" className="mr-2 size-4" />
{t("models.upload.button")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{phase === "uploading"
? t("models.upload.uploading.title")
: phase === "success"
? t("models.upload.success.title")
: t("models.upload.dialog.title")}
</DialogTitle>
<DialogDescription>
{phase === "select" && t("models.upload.dropzone.hint")}
{phase === "uploading" && t("models.upload.uploading.hint")}
{phase === "success" && t("models.upload.success.scanHint")}
{phase === "error" && errorMessage}
</DialogDescription>
</DialogHeader>
{phase === "select" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="model-file">{t("models.upload.selectedFile")}</Label>
<Input
id="model-file"
type="file"
accept=".nef"
onChange={handleFileChange}
data-testid="model-file-input"
/>
{file && (
<p className="text-muted-foreground text-xs">
{file.name} · {(file.size / 1024 / 1024).toFixed(1)} MB
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="model-name">
{t("models.upload.field.name")}
</Label>
<Input
id="model-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="YOLOv5s KL520"
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-chip">
{t("models.upload.field.targetChip")}
</Label>
<Select value={targetChip} onValueChange={(v) => setTargetChip(v as TargetChip)}>
<SelectTrigger id="model-chip">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kl520">KL520</SelectItem>
<SelectItem value="kl720">KL720</SelectItem>
<SelectItem value="kl630">KL630</SelectItem>
<SelectItem value="kl730">KL730</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{phase === "uploading" && (
<div className="space-y-3" role="progressbar" aria-valuenow={progress} aria-valuemin={0} aria-valuemax={100}>
<p className="truncate text-sm font-medium">{file?.name}</p>
<Progress value={progress} />
<p className="text-muted-foreground text-sm" suppressHydrationWarning>
{progress}% · {file ? `${(((file.size * progress) / 100) / 1024 / 1024).toFixed(1)} MB / ${(file.size / 1024 / 1024).toFixed(1)} MB` : ""}
</p>
</div>
)}
{phase === "error" && errorMessage && (
<p role="alert" className="text-destructive text-sm">
{errorMessage}
</p>
)}
<DialogFooter>
{phase === "select" && (
<>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
{t("models.upload.action.cancel")}
</Button>
<Button
onClick={handleStart}
disabled={!file || !name.trim()}
data-testid="model-upload-start"
>
{t("models.upload.action.start")}
</Button>
</>
)}
{phase === "uploading" && (
<Button
variant="outline"
onClick={handleCancelUpload}
data-testid="model-upload-cancel"
>
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
{t("models.upload.action.cancel")}
</Button>
)}
{phase === "success" && (
<Button onClick={() => handleOpenChange(false)} data-testid="model-upload-close">
{t("common.close")}
</Button>
)}
{phase === "error" && (
<>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
<X aria-hidden="true" className="mr-2 size-4" />
{t("common.close")}
</Button>
<Button onClick={handleStart}>
{t("common.retry")}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,156 @@
"use client";
/**
* PairingCountdown Pairing Token
*
*
* - `.autoflow/03-design/flows/flow-pairing.md` §4.4 + 0:30 toast
* - `.autoflow/03-design/components.md` §10.2PairingTokenCard
*
*
* - > 10:00 `text-foreground` + `bg-primary`
* - 10:00 3:00 `text-warning` + `bg-warning``font-medium`
* - 3:00 0:30 `text-destructive` + `bg-destructive` `ring-1 ring-destructive/50`
* - 0:30 + toast
* - 00:00 `text-muted-foreground line-through` + `onExpire`
*
*
* - 1 tick `setInterval`
* - prop `expiresAt` parent drift
* - SSR safe TTL hydration mismatch
* - `aria-live="polite"` SR announce
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
type Phase = "normal" | "warning" | "danger" | "expired";
interface PairingCountdownProps {
/** ISO 8601 過期時間(後端 / store 給的) */
expiresAt: string;
/** ISO 8601 產生時間,用來算進度條百分比 */
createdAt: string;
/** 倒數歸零時觸發一次 */
onExpire?: () => void;
className?: string;
}
function decidePhase(remainingMs: number): Phase {
if (remainingMs <= 0) return "expired";
if (remainingMs <= 3 * 60 * 1000) return "danger";
if (remainingMs <= 10 * 60 * 1000) return "warning";
return "normal";
}
function formatMmSs(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const mm = Math.floor(s / 60)
.toString()
.padStart(2, "0");
const ss = (s % 60).toString().padStart(2, "0");
return `${mm}:${ss}`;
}
export function PairingCountdown({
expiresAt,
createdAt,
onExpire,
className,
}: PairingCountdownProps) {
const t = useT();
// 用 useMemo 快取時間戳,避免每 render 都 new Date
const { expiresAtMs, totalMs } = useMemo(() => {
const e = new Date(expiresAt).getTime();
const c = new Date(createdAt).getTime();
// totalMs = 整個 TTL用於算進度條百分比剩餘 / 總)
return { expiresAtMs: e, totalMs: Math.max(1, e - c) };
}, [expiresAt, createdAt]);
// SSR 初始值:完整 TTLclient mount 後立即重算(見下方 effect
const [remainingMs, setRemainingMs] = useState(totalMs);
const expiredFiredRef = useRef(false);
const toastFiredRef = useRef(false);
useEffect(() => {
// 每秒 tick
const tick = () => {
const now = Date.now();
const remain = Math.max(0, expiresAtMs - now);
setRemainingMs(remain);
// ≤ 30 秒發一次 toast只發一次
if (remain > 0 && remain <= 30 * 1000 && !toastFiredRef.current) {
toastFiredRef.current = true;
toast.warning(t("pairing.toast.expiringSoon"));
}
// 歸零觸發 onExpire只發一次
if (remain <= 0 && !expiredFiredRef.current) {
expiredFiredRef.current = true;
onExpire?.();
}
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [expiresAtMs, onExpire, t]);
const phase = decidePhase(remainingMs);
const remainingSec = Math.ceil(remainingMs / 1000);
const remainingLabel = formatMmSs(remainingSec);
// 進度條百分比 = 剩餘 / 總
const percent = Math.max(0, Math.min(100, (remainingMs / totalMs) * 100));
return (
<div className={cn("space-y-1.5", className)} data-testid="pairing-countdown">
<div
className="flex items-center justify-between text-sm"
aria-live="polite"
role="status"
>
<span
className={cn(
"tabular-nums",
phase === "normal" && "text-foreground",
phase === "warning" && "text-warning font-medium",
phase === "danger" && "text-destructive font-semibold",
phase === "expired" && "text-muted-foreground line-through",
)}
data-phase={phase}
>
{phase === "expired"
? t("pairing.token.expired.label")
: t("pairing.timeRemaining").replace("{time}", remainingLabel)}
</span>
</div>
{/* 進度條 */}
<div
className={cn(
"bg-muted h-1.5 w-full overflow-hidden rounded-full",
phase === "danger" && "ring-destructive/40 ring-1",
)}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
aria-label={t("pairing.timeRemaining").replace("{time}", remainingLabel)}
>
<div
className={cn(
"h-full transition-[width] duration-500 ease-linear",
phase === "normal" && "bg-primary",
phase === "warning" && "bg-warning",
phase === "danger" && "bg-destructive",
phase === "expired" && "bg-muted-foreground/40",
)}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,224 @@
"use client";
/**
* PairingTokenCard Pairing Token /
*
*
* - `.autoflow/03-design/components.md` §10.2
* - `.autoflow/03-design/wireframes/wf-pairing.md`
* - `.autoflow/03-design/flows/flow-pairing.md` §4.4-§4.5 +
*
*
* - API token `vAc_` + 32 hex 36
* - **** 1 `vAc_` + 16 hex 2 16 hex
* - **簿 36 **`navigator.clipboard.writeText(token)`
* - `select-all` token
*
*
* - Token `aria-label="Pairing token"` + `role="text"`SR
* - `aria-live="polite"`
*/
import { useEffect, useState } from "react";
import { Check, Copy, Link as LinkIcon, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { PairingCountdown } from "@/components/pairing/pairing-countdown";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { PAIRING_TOKEN_PREFIX, type PairingToken } from "@/types/pairing";
interface PairingTokenCardProps {
/** 目前的 tokennull = 尚未產生 */
token: PairingToken | null;
/** API call 進行中(重新產生時按鈕會 disable */
isGenerating?: boolean;
/** 使用者按「重新產生」並在 AlertDialog 確認後觸發 */
onRegenerate: () => void;
/** Token 倒數歸零時觸發,交由 parent 切換 UI 狀態 */
onExpire?: () => void;
className?: string;
}
/**
* 36 token
* - Line 1: `vAc_` + 16 hex20
* - Line 2: 16 hex
*
* token
*/
function splitForDisplay(token: string): [string, string] {
if (token.startsWith(PAIRING_TOKEN_PREFIX) && token.length === 36) {
return [token.slice(0, 20), token.slice(20)];
}
// 非預期格式,回整段 + 空字串
return [token, ""];
}
export function PairingTokenCard({
token,
isGenerating = false,
onRegenerate,
onExpire,
className,
}: PairingTokenCardProps) {
const t = useT();
const [justCopied, setJustCopied] = useState(false);
// 用 state + 1 秒 tick 判斷過期(避免在 render 期間呼叫 Date.now —
// 觸發 react-hooks/purity。PairingCountdown 內部也有一個 tick
// 但為了讓按鈕 disabled / line-through 正確反映過期,這裡也需要一個。
const [isExpired, setIsExpired] = useState(false);
useEffect(() => {
if (!token) return;
const expiresAtMs = new Date(token.expiresAt).getTime();
// 從 setInterval callback 更新 state — 符合 react-hooks/set-state-in-effect
// 所允許的「從外部系統 subscription 回呼中 setState」模式。
const id = window.setInterval(() => {
setIsExpired(expiresAtMs - Date.now() <= 0);
}, 1_000);
return () => window.clearInterval(id);
}, [token]);
async function handleCopy() {
if (!token) return;
try {
await navigator.clipboard.writeText(token.token);
setJustCopied(true);
toast.success(t("pairing.toast.copied"));
window.setTimeout(() => setJustCopied(false), 2000);
} catch {
toast.error(t("common.error"));
}
}
const [line1, line2] = token ? splitForDisplay(token.token) : ["", ""];
return (
<Card className={className} data-testid="pairing-token-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<LinkIcon aria-hidden="true" className="size-4" />
{t("pairing.token.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("pairing.step1.description")}
</p>
{/* Token 顯示區 */}
<div
className={cn(
"bg-muted select-all rounded-md p-4 font-mono text-xl tracking-wider sm:text-xl",
"text-lg sm:text-xl", // Mobile 降級(< 640 用 text-lg
isExpired && "text-muted-foreground line-through",
)}
role="text"
aria-label={token ? `Pairing token ${token.token}` : "Pairing token"}
data-testid="pairing-token-display"
>
{token ? (
<>
<div>{line1}</div>
{line2 && <div>{line2}</div>}
</>
) : (
<div className="text-muted-foreground text-sm font-normal not-italic">
{t("common.loading")}
</div>
)}
</div>
{/* 操作 */}
<div className="flex flex-wrap gap-2">
<Button
onClick={handleCopy}
disabled={!token || isExpired}
aria-live="polite"
data-testid="pairing-copy-button"
>
{justCopied ? (
<>
<Check aria-hidden="true" className="mr-2 size-4" />
{t("pairing.copied")}
</>
) : (
<>
<Copy aria-hidden="true" className="mr-2 size-4" />
{t("pairing.copy")}
</>
)}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={isGenerating}
data-testid="pairing-regenerate-trigger"
>
<RefreshCw aria-hidden="true" className="mr-2 size-4" />
{t("pairing.regenerate")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("pairing.regenerateConfirm.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("pairing.regenerateConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={onRegenerate}
data-testid="pairing-regenerate-confirm"
>
{t("pairing.regenerate")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* 倒數 */}
{token && (
<PairingCountdown
expiresAt={token.expiresAt}
createdAt={token.createdAt}
onExpire={onExpire}
/>
)}
{/* 產生時間 */}
{token && (
<p className="text-muted-foreground text-xs">
{t("pairing.generatedAt").replace(
"{time}",
new Date(token.createdAt).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
}),
)}
</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,37 @@
/**
* StoreHydration client mount store hydration
*
*
* - session-store / device-preferences-store localStorage
* SSR localStorage store hydration mismatch
* - auth-storeOF2 BFF cookie session localStorage
* client mount `hydrate()` `GET /api/auth/me` user
*
* OF2
* - `setApiTokenGetter`cookie api token getter
* - `useAuthStore.hydrateFromStorage()` `useAuthStore.hydrate()`fetch me
*
* oidc-tdd.md §10.1
*/
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { useDevicePreferencesStore } from "@/stores/device-preferences-store";
import { useSessionStore } from "@/stores/session-store";
export function StoreHydration() {
useEffect(() => {
// OF2auth-store 改 fetch /api/auth/me 取得當前 userBFF cookie session
void useAuthStore.getState().hydrate();
// session-store 仍走 localStorage裝置偏好、最近選的 session 等本地資料)
useSessionStore.getState().hydrateFromStorage();
// device-preferences-store 使用 zustand persist因 skipHydration=true 需手動 rehydrate
void useDevicePreferencesStore.persist.rehydrate();
}, []);
// 純 side-effect 元件,不 render 任何 DOM
return null;
}

View File

@ -0,0 +1,29 @@
"use client";
/**
* ThemeProvider visionA Cloud
*
* next-themes ThemeProvider
* - attribute="class" <html> `.dark` globals.css `.dark { ... }`
* - defaultTheme="system"
* - enableSystem system
* - disableTransitionOnChange transition
*
* 使 root layout {children} F4 ThemeToggle UI
*/
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...rest }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...rest}
>
{children}
</NextThemesProvider>
);
}

View File

@ -0,0 +1 @@
此目錄存放 Shadcn 風基礎 UI 元件Button / Card / Dialog 等),由 F3 任務填入。

View File

@ -0,0 +1,207 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
/**
* AlertDialog Shadcn Radix AlertDialog
*
* local-tool/frontend/src/components/ui/alert-dialog.tsx100% 沿
*
* Dialog
* - ESC 使 Action Cancel
* - token
*
* tw-animate-css
*/
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Avatar Shadcn Radix Avatar
*
* Shadcn UI New York stylelocal-tool F4
* 使 `React.ComponentProps<typeof Primitive.X>` + data-slot ui/
*
*
* Avatar size-8 className
* AvatarImage Radix
* AvatarFallback / email
*
* 使UserMenu trigger
*/
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-xs font-medium",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarFallback, AvatarImage };

View File

@ -0,0 +1,55 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Badge Shadcn asChild <a>
*
* local-tool/frontend/src/components/ui/badge.tsx100% 沿
*
* variantsdefault / secondary / destructive / outline / ghost / link
*/
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,55 @@
/**
* Button
*
* F3 Tailwind / CVA / radix-ui Slot / jest-dom matcher
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
describe("<Button />", () => {
it("能渲染 children 並標記為 button", () => {
render(<Button></Button>);
const btn = screen.getByRole("button", { name: "儲存" });
expect(btn).toBeInTheDocument();
expect(btn.tagName).toBe("BUTTON");
});
it("預設 variant=default / size=default透過 data-* 驗證,不綁 Tailwind class", () => {
render(<Button>Default</Button>);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("data-variant", "default");
expect(btn).toHaveAttribute("data-size", "default");
});
it("variant 與 size prop 會反映到 data 屬性", () => {
render(
<Button variant="destructive" size="sm">
Delete
</Button>,
);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("data-variant", "destructive");
expect(btn).toHaveAttribute("data-size", "sm");
});
it("disabled 時 pointer-events 與 aria 正確", () => {
render(<Button disabled>Disabled</Button>);
const btn = screen.getByRole("button");
expect(btn).toBeDisabled();
});
it("asChild 將樣式傳給子元素(此處用 <a>),而非外包一層 <button>", () => {
render(
<Button asChild>
<a href="/somewhere">Link</a>
</Button>,
);
// asChild 下 Slot 會把 className 與 data-* 合併到子元素;不應存在 <button>
expect(screen.queryByRole("button")).not.toBeInTheDocument();
const link = screen.getByRole("link", { name: "Link" });
expect(link).toHaveAttribute("data-slot", "button");
expect(link).toHaveAttribute("data-variant", "default");
});
});

View File

@ -0,0 +1,75 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Button Shadcn New York style
*
* local-tool/frontend/src/components/ui/button.tsx100% 沿
*
*
* - variantdefault / destructive / outline / secondary / ghost / link
* - sizedefault / xs / sm / lg / icon / icon-xs / icon-sm / icon-lg
*
* `asChild` radix-ui Slot <Button asChild><Link>...</Link></Button>
*/
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,75 @@
/**
* Card
*
* Card Header / Title / Description / Content / Footer / Action
* data-slot Card data-slot has-data
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./card";
describe("<Card />", () => {
it("能渲染完整組合並正確標記 data-slot", () => {
render(
<Card data-testid="card-root">
<CardHeader>
<CardTitle>Pairing Token</CardTitle>
<CardDescription> local agent token</CardDescription>
<CardAction>
<button type="button"></button>
</CardAction>
</CardHeader>
<CardContent>vAc_abcdef...</CardContent>
<CardFooter> 14:52</CardFooter>
</Card>,
);
// 結構
expect(screen.getByTestId("card-root")).toHaveAttribute(
"data-slot",
"card",
);
expect(screen.getByText("Pairing Token")).toHaveAttribute(
"data-slot",
"card-title",
);
expect(screen.getByText("連上 local agent 的 token")).toHaveAttribute(
"data-slot",
"card-description",
);
expect(screen.getByText("vAc_abcdef...")).toHaveAttribute(
"data-slot",
"card-content",
);
expect(screen.getByText("剩餘 14:52")).toHaveAttribute(
"data-slot",
"card-footer",
);
// CardAction 可被 querySelector 以 data-slot 找到
const action = screen
.getByTestId("card-root")
.querySelector('[data-slot="card-action"]');
expect(action).not.toBeNull();
expect(action?.textContent).toBe("重新產生");
});
it("className 透過 cn() 合併tailwind-merge 解衝突)", () => {
render(
<Card className="border-4" data-testid="card-root">
content
</Card>,
);
const card = screen.getByTestId("card-root");
// cn() 會把 className 後綴合併;我們只確認 className 被套用
expect(card.className).toContain("border-4");
});
});

View File

@ -0,0 +1,100 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Card Shadcn New York style
*
* local-tool/frontend/src/components/ui/card.tsx100% 沿
*
* Card / CardHeader / CardTitle / CardDescription / CardAction / CardContent / CardFooter
* CardAction `has-data-[slot=card-action]` Header
*/
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Checkbox Shadcn Radix Checkbox
*
* local-tool/frontend/src/components/ui/checkbox.tsx100% 沿
*
* default / checked / indeterminate / disabled
* Focus `focus-visible:ring-[3px]` WCAG AA
*/
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,174 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
/**
* Dialog Shadcn ModalRadix Dialog
*
* local-tool/frontend/src/components/ui/dialog.tsx100% 沿
*
*
* Dialog
* DialogTrigger
* DialogContent DialogPortal + DialogOverlay + Close X
* DialogHeader
* DialogTitle
* DialogDescription
* DialogFooter showCloseButton
*
* tw-animate-css`data-[state=open]:animate-in` utility
*/
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,282 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* DropdownMenu Shadcn Radix DropdownMenu
*
* Shadcn UI New York stylelocal-tool F4
* `ui/` 使 `React.ComponentProps<typeof Primitive.X>` + data-slot
*
*
* DropdownMenu
* DropdownMenuTrigger
* DropdownMenuContent
* DropdownMenuLabel
* DropdownMenuItem
* DropdownMenuCheckboxItem
* DropdownMenuRadioGroup / Item
* DropdownMenuSeparator
* DropdownMenuSub / SubTrigger / SubContent
*
* tw-animate-cssglobals.css import
*
* 使
* - UserMenuHeader avatar
* -
*/
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return (
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@ -0,0 +1,57 @@
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* EmptyState
*
* local-tool/frontend/src/components/ui/empty-state.tsx100% 沿
*
* / icon + + + /
*/
interface EmptyStateAction {
label: string;
onClick: () => void;
}
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: EmptyStateAction;
secondaryAction?: EmptyStateAction;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
secondaryAction,
}: EmptyStateProps) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-4 text-center">
<div className="bg-muted rounded-full p-4">
<Icon className="text-muted-foreground h-8 w-8" />
</div>
<div className="space-y-1">
<h3 className="text-base font-medium">{title}</h3>
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
</div>
{(action || secondaryAction) && (
<div className="flex gap-2">
{action && (
<Button onClick={action.onClick} size="sm">
{action.label}
</Button>
)}
{secondaryAction && (
<Button onClick={secondaryAction.onClick} size="sm" variant="outline">
{secondaryAction.label}
</Button>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,30 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Input Shadcn
*
* local-tool/frontend/src/components/ui/input.tsx100% 沿
*
* - h-9rounded-mdshadow-xs
* - `aria-invalid` `aria-invalid:border-destructive`
* - Focus `focus-visible:ring-[3px]` ring WCAG AA
*/
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Label Shadcn Radix Label
*
* local-tool/frontend/src/components/ui/label.tsx100% 沿
*
*
* - peer-disabled / group-data-[disabled=true]
* - Input Radix Label htmlFor
*/
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@ -0,0 +1,39 @@
"use client";
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Progress Shadcn Radix Progress
*
* local-tool/frontend/src/components/ui/progress.tsx100% 沿
*
* 使PairingToken
* `translateX` reflow width transition
*/
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* ScrollArea Shadcn Radix ScrollArea
*
* local-tool/frontend/src/components/ui/scroll-area.tsx100% 沿
*
* Windows macOS
* vertical ScrollBar horizontal <ScrollBar orientation="horizontal" />
*/
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Select Shadcn Radix Select
*
* local-tool/frontend/src/components/ui/select.tsx100% 沿
*
* Select / SelectTrigger / SelectValue / SelectContent / SelectItem /
* SelectGroup / SelectLabel / SelectSeparator / SelectScrollUp/Down
*
* SelectTrigger size`sm` = h-8`default` = h-9
*/
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Separator Shadcn Radix Separator
*
* local-tool/frontend/src/components/ui/separator.tsx100% 沿
*
* decorative=true role
*/
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Skeleton Loading
*
* local-tool shadcn
*
* CLSlayout shift Tailwind `animate-pulse`
* 使 className h-4 w-24 rounded
*/
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@ -0,0 +1,71 @@
"use client";
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Slider Shadcn Radix Slider
*
* local-tool/frontend/src/components/ui/slider.tsx100% 沿
*
* / range value Thumb
* 使inference confidence threshold0-1
*/
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@ -0,0 +1,52 @@
"use client";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
/**
* Toaster Sonner Shadcn
*
* local-tool/frontend/src/components/ui/sonner.tsx Zustand store next-themes
*
*
* - local-tool useSettingsStore theme settings-storetheme next-themes
* `useTheme()` theme 'system'sonner media query
* - icon / CSS 沿 local-tool
*
* 使 root layout <Toaster /> `toast()` / `toast.success()` API
*/
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,46 @@
import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Spinner Loading
*
* local-tool 使 <Loader2Icon className="animate-spin" /> sonner.tsx loading icon
* 便 Button loading / loading 使
*
* sizesmsize-4/ mdsize-5/ lgsize-8
*/
interface SpinnerProps extends Omit<React.ComponentProps<"span">, "children"> {
size?: "sm" | "md" | "lg";
/** 螢幕閱讀器朗讀文字預設「Loading」— 呼叫端若有語系請覆寫 */
label?: string;
}
const sizeClass: Record<NonNullable<SpinnerProps["size"]>, string> = {
sm: "size-4",
md: "size-5",
lg: "size-8",
};
function Spinner({
size = "md",
label = "Loading",
className,
...props
}: SpinnerProps) {
return (
<span
role="status"
aria-live="polite"
data-slot="spinner"
className={cn("inline-flex items-center justify-center", className)}
{...props}
>
<Loader2Icon className={cn("animate-spin", sizeClass[size])} aria-hidden />
<span className="sr-only">{label}</span>
</span>
);
}
export { Spinner };

View File

@ -0,0 +1,100 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Tabs Shadcn Radix Tabs
*
* local-tool/frontend/src/components/ui/tabs.tsx100% 沿
*
* variant
* - defaultTabsList `bg-muted`active
* - lineTabsList active `after:*` utility
*/
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

View File

@ -0,0 +1,76 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Tooltip Shadcn Radix Tooltip
*
* Shadcn UI New York stylelocal-tool F4
*
* 使
* <TooltipProvider> root layoutApp
* <Tooltip>
* <TooltipTrigger>...</TooltipTrigger>
* <TooltipContent></TooltipContent>
* </Tooltip>
*
* 使
* - Header Tunnel RTT
* -
* - Sidebar icon
*
* tw-animate-css
*/
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@ -0,0 +1 @@
此目錄存放自訂 hooksuseWebSocket 等),由後續任務填入。

View File

@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { useFetch } from "./use-fetch";
/**
* useFetch
*
* mount tiny component DOM Testing Library
*/
function Probe({
path,
enabled,
}: {
path: string;
enabled?: boolean;
}) {
const { data, error, isLoading } = useFetch<{ hello: string }>(path, {
enabled,
});
if (isLoading) return <div data-testid="state">loading</div>;
if (error) return <div data-testid="state">error: {error.message}</div>;
return <div data-testid="state">data: {data?.hello ?? "none"}</div>;
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("useFetch", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("mount 時自動 fetch成功後顯示 data", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, { success: true, data: { hello: "world" } }),
);
render(<Probe path="/api/test" />);
expect(screen.getByTestId("state")).toHaveTextContent("loading");
await waitFor(() =>
expect(screen.getByTestId("state")).toHaveTextContent("data: world"),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("enabled=false 時不 fetch狀態為空 data", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, { success: true, data: { hello: "world" } }),
);
render(<Probe path="/api/test" enabled={false} />);
// 等一個 tick 確認不會 fetch
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(fetchMock).not.toHaveBeenCalled();
expect(screen.getByTestId("state")).toHaveTextContent("data: none");
});
it("API 錯誤時 error state 有值", async () => {
fetchMock.mockResolvedValue(
jsonResponse(500, {
success: false,
error: { code: "INTERNAL_ERROR", message: "boom" },
}),
);
render(<Probe path="/api/fail" />);
await waitFor(() =>
expect(screen.getByTestId("state")).toHaveTextContent("error: boom"),
);
});
it("path 變動會觸發 refetch", async () => {
fetchMock.mockImplementation((url: string) => {
if (url.endsWith("/api/a")) {
return Promise.resolve(
jsonResponse(200, { success: true, data: { hello: "A" } }),
);
}
return Promise.resolve(
jsonResponse(200, { success: true, data: { hello: "B" } }),
);
});
const { rerender } = render(<Probe path="/api/a" />);
await waitFor(() =>
expect(screen.getByTestId("state")).toHaveTextContent("data: A"),
);
rerender(<Probe path="/api/b" />);
await waitFor(() =>
expect(screen.getByTestId("state")).toHaveTextContent("data: B"),
);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

View File

@ -0,0 +1,137 @@
/**
* useFetch data fetching hook SWR / TanStack Query
*
* F5
* - server state library
* - `data / error / isLoading / refetch`
* - caller options.enabled=false fetch auth ready
* - 使 AbortController race condition / mount
* - Phase 1 SWR / TanStack Query
*
*
* const { data, error, isLoading, refetch } = useFetch<Device[]>("/api/devices");
*/
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type RequestOptions } from "@/lib/api";
export interface UseFetchOptions<T> extends Omit<RequestOptions, "signal"> {
/** 是否啟用自動 fetch預設 true。設 false 可手動呼叫 refetch */
enabled?: boolean;
/** 初始 dataSSR hydrate 或 fallback */
initialData?: T;
/**
* refetch
* path `[deviceId]`path deps
*/
deps?: ReadonlyArray<unknown>;
}
export interface UseFetchResult<T> {
data: T | undefined;
error: Error | null;
isLoading: boolean;
/** 手動觸發 refetch回傳新的 data 或 throw */
refetch: () => Promise<T | undefined>;
}
/**
* `api.get<T>(path)` React hook
*
*
* - path keypath fetch
* - unmount path abortstate
* - stale-while-revalidate / dedup
*/
export function useFetch<T>(
path: string,
options: UseFetchOptions<T> = {},
): UseFetchResult<T> {
const { enabled = true, initialData, deps = [], ...requestOptions } = options;
const [data, setData] = useState<T | undefined>(initialData);
const [error, setError] = useState<Error | null>(null);
/**
* fetchState
* - "idle" disable loading
* - "loading"
* - "success" / "error"
*
* boolean `isLoading`
* React 19 `react-hooks/set-state-in-effect` effect body setState
* setStatedoFetch disable enabled
* effect
*
* caller boolean `isLoading` API
*/
const [fetchState, setFetchState] = useState<"idle" | "loading" | "success" | "error">(
enabled ? "loading" : "idle",
);
// 保存 options ref 避免 useCallback deps 抖動。於 effect 內更新以符合 React 19 lint。
const optionsRef = useRef(requestOptions);
useEffect(() => {
optionsRef.current = requestOptions;
});
/** 目前在途的 abort controller用來取消 stale 請求) */
const activeCtrlRef = useRef<AbortController | null>(null);
const doFetch = useCallback(async (): Promise<T | undefined> => {
// 取消前一次
activeCtrlRef.current?.abort();
const ctrl = new AbortController();
activeCtrlRef.current = ctrl;
setFetchState("loading");
setError(null);
try {
const result = await api.get<T>(path, {
...optionsRef.current,
signal: ctrl.signal,
});
// 若這個 request 已被取消,不要更新 state另一個 request 正在跑)
if (ctrl.signal.aborted) return undefined;
setData(result);
setFetchState("success");
return result;
} catch (err) {
if (ctrl.signal.aborted) return undefined;
const e = err instanceof Error ? err : new Error(String(err));
setError(e);
setFetchState("error");
throw e;
}
}, [path]);
useEffect(() => {
if (!enabled) {
// enabled=false 時不發 APIfetchState 仍保持原值(若原先已是 success 的資料仍然顯示)
return;
}
// 推到下一個 microtask 才開始 fetch
// - 避免 React 19 lint `react-hooks/set-state-in-effect`effect body 同步 setState
// - 不影響行為,只是把 setFetchState("loading") 排到 microtask queue
// - cleanup 時若 effect 在尚未啟動前被解除,由 ctrl.abort() 統一處理
queueMicrotask(() => {
void doFetch().catch(() => {
// error state 已在 doFetch 設置,這裡 catch 只是為了避免 unhandled rejection
});
});
return () => {
activeCtrlRef.current?.abort();
};
// deps: path + caller 提供的 extra deps + enabled
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path, enabled, ...deps]);
// 對外仍提供 boolean `isLoading`,由 fetchState 派生
const isLoading = enabled && fetchState === "loading";
return { data, error, isLoading, refetch: doFetch };
}

View File

@ -0,0 +1,34 @@
/**
* useTunnelStatus tunnel
*
*
* - `.autoflow/03-design/flows/flow-offline-handling.md` §1-3tunnel UI
* - `.autoflow/04-architecture/api/api-spec.md` §2 `GET /api/pairing/status` / §9 WS `/ws/pairing/status`
*
*
* - source of truth`session-store`
* - hook React component tunnel
* - ** polling / WS **F6/F7 store
* - Phase 1 hook WS `/ws/pairing/status` fallback polling `/api/pairing/status`
*
* Header `TunnelStatusIndicator` 便 spread
*/
"use client";
import { useSessionStore, type TunnelStatus } from "@/stores/session-store";
export interface UseTunnelStatusResult {
status: TunnelStatus;
rtt: number | null;
lastSeenAt: string | null;
}
export function useTunnelStatus(): UseTunnelStatusResult {
// Zustand 的 selector 能保證只在對應片段變動時 re-render
const status = useSessionStore((s) => s.tunnelStatus);
const rtt = useSessionStore((s) => s.rtt);
const lastSeenAt = useSessionStore((s) => s.lastSeenAt);
return { status, rtt, lastSeenAt };
}

View File

@ -0,0 +1,157 @@
/**
* useWebSocket visionA Cloud
*
* local-tool `hooks/use-websocket.ts`
* - base URL `NEXT_PUBLIC_WS_BASE` API base
* - 沿退 10scaller close
*
* OF2 WS OIDC BFF hook
* token querystringOF2 auth-store.token
* OF7 / Phase 1 WS **** querystring token
* (a) `Sec-WebSocket-Protocol: Bearer,<token>` + server subprotocol
* (b) WS ticketHTTP ~60s ticket WS `?ticket=...`
* (c) HttpOnly cookie + SameSite=Lax WS API visiona_session
* RFC 6750 §5.1.2OWASP ASVS V3.5.3security.md §14.3
*
*
* - WS endpoint caller onMessage
* - F6/F7 `/ws/devices/events` / `/ws/pairing/status` 使
*/
"use client";
import { useEffect, useRef } from "react";
import { getWsBaseUrl } from "@/lib/api";
export interface UseWebSocketOptions {
/** 收到訊息時呼叫 */
onMessage: (data: unknown) => void;
/** 連線開啟時呼叫(可選) */
onOpen?: () => void;
/** 連線關閉時呼叫(可選) */
onClose?: () => void;
/** false 時不建立連線(例如等使用者登入) */
enabled?: boolean;
/** 重連最小間隔ms預設 1000 */
minReconnectMs?: number;
/** 重連最大間隔ms預設 10000 */
maxReconnectMs?: number;
}
/**
* server WS hook
*
* handle
* - `.send(data)`JSON.stringify
* - `.close()`
* - `.isOpen`
*
*
* - 使 ref onMessage effect callback reference
* - handle useRef React identity 使 effect
*/
export function useWebSocket(
path: string,
options: UseWebSocketOptions,
): {
send: (data: unknown) => void;
close: () => void;
} {
const {
onMessage,
onOpen,
onClose,
enabled = true,
minReconnectMs = 1000,
maxReconnectMs = 10_000,
} = options;
// 用 ref 儲存最新 callback避免每次 render 重連
const onMessageRef = useRef(onMessage);
const onOpenRef = useRef(onOpen);
const onCloseRef = useRef(onClose);
// 在 effect 內更新 refReact 19 lint: react-hooks/refs 禁止 render 期間更新 ref
useEffect(() => {
onMessageRef.current = onMessage;
onOpenRef.current = onOpen;
onCloseRef.current = onClose;
});
const wsRef = useRef<WebSocket | null>(null);
const closedRef = useRef<boolean>(false);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectDelayRef = useRef<number>(minReconnectMs);
useEffect(() => {
if (!enabled) return;
closedRef.current = false;
reconnectDelayRef.current = minReconnectMs;
function buildUrl(): string {
const base = getWsBaseUrl();
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
// OF2不再附加 token 到 querystringauth-store 已無 token 欄位)。
// BFF 模式下若 WS 與 API 同 origin瀏覽器會自動帶 visiona_session cookie
// 若跨 origin待 OF7 / Phase 1 補上述 (a)/(b)/(c) 之一的安全認證機制。
return `${base}${normalizedPath}`;
}
function connect() {
if (closedRef.current) return;
const ws = new WebSocket(buildUrl());
wsRef.current = ws;
ws.onopen = () => {
reconnectDelayRef.current = minReconnectMs; // 成功連線重置退避
onOpenRef.current?.();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessageRef.current(data);
} catch {
// 忽略非 JSON 訊息(例如 ping
}
};
ws.onclose = () => {
onCloseRef.current?.();
if (closedRef.current) return;
// 指數退避×2上限 maxReconnectMs
const delay = reconnectDelayRef.current;
reconnectDelayRef.current = Math.min(delay * 2, maxReconnectMs);
reconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
// 觸發 onclose不另外處理
ws.close();
};
}
connect();
return () => {
closedRef.current = true;
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
wsRef.current = null;
};
}, [path, enabled, minReconnectMs, maxReconnectMs]);
return {
send: (data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
},
close: () => {
closedRef.current = true;
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
},
};
}

View File

@ -0,0 +1 @@
此目錄存放 API client、i18n、auth helper 等共用模組F1 已建立 utils.ts。

View File

@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiError,
NetworkError,
ParseError,
TimeoutError,
api,
getApiBaseUrl,
getWsBaseUrl,
} from "./api";
/**
* api.ts OF2 / BFF cookie session
*
*
* - Base URL env trailing slashWS
* - envelope
* - envelope ApiError
* - Non-2xx envelope ApiError with HTTP status
* - 401 envelope ApiError code = UNAUTHORIZEDauth-store
* - `credentials: 'include'`cookie
* - Authorization headerOF2 token getter
* - Timeout / Abort
* - Network error / Parse error
* - 204 No Content
*/
describe("api.ts", () => {
// 每個測試用獨立的 fetch mock避免交叉污染
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
/* ------------------------------------------------------------------------ */
/* Base URL */
/* ------------------------------------------------------------------------ */
describe("getApiBaseUrl", () => {
it("讀取 NEXT_PUBLIC_API_BASE 且移除 trailing slash", () => {
const orig = process.env.NEXT_PUBLIC_API_BASE;
process.env.NEXT_PUBLIC_API_BASE = "https://api.example.com/";
expect(getApiBaseUrl()).toBe("https://api.example.com");
process.env.NEXT_PUBLIC_API_BASE = orig;
});
// Phase 0.7 stage deployment fix見 .autoflow/05-implementation/phase-0.7-frontend-fix.md
// 修法browser 環境下 env 未設 → 同 origin其他環境SSR / Node test保留 localhost fallback
it("env 設為空字串 → 同 origin相對路徑讓瀏覽器自動接當前 origin", () => {
const orig = process.env.NEXT_PUBLIC_API_BASE;
process.env.NEXT_PUBLIC_API_BASE = "";
expect(getApiBaseUrl()).toBe("");
if (orig !== undefined) process.env.NEXT_PUBLIC_API_BASE = orig;
else delete process.env.NEXT_PUBLIC_API_BASE;
});
it("未設定時browser 環境jsdomfallback 到同 origin空字串", () => {
const orig = process.env.NEXT_PUBLIC_API_BASE;
delete process.env.NEXT_PUBLIC_API_BASE;
// vitest 預設用 jsdomtypeof window !== "undefined"
expect(getApiBaseUrl()).toBe("");
if (orig !== undefined) process.env.NEXT_PUBLIC_API_BASE = orig;
});
});
describe("getWsBaseUrl", () => {
it("優先使用 NEXT_PUBLIC_WS_BASE", () => {
const origWs = process.env.NEXT_PUBLIC_WS_BASE;
process.env.NEXT_PUBLIC_WS_BASE = "wss://api.example.com/ws";
expect(getWsBaseUrl()).toBe("wss://api.example.com/ws");
process.env.NEXT_PUBLIC_WS_BASE = origWs;
});
it("未設定時由 API base 推導 http→ws, https→wss", () => {
const origApi = process.env.NEXT_PUBLIC_API_BASE;
const origWs = process.env.NEXT_PUBLIC_WS_BASE;
delete process.env.NEXT_PUBLIC_WS_BASE;
process.env.NEXT_PUBLIC_API_BASE = "https://api.example.com";
expect(getWsBaseUrl()).toBe("wss://api.example.com");
process.env.NEXT_PUBLIC_API_BASE = "http://localhost:3721";
expect(getWsBaseUrl()).toBe("ws://localhost:3721");
process.env.NEXT_PUBLIC_API_BASE = origApi;
if (origWs !== undefined) process.env.NEXT_PUBLIC_WS_BASE = origWs;
});
});
/* ------------------------------------------------------------------------ */
/* Happy path */
/* ------------------------------------------------------------------------ */
it("GET 成功 envelope 回傳 data", async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ success: true, data: { hello: "world" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const result = await api.get<{ hello: string }>("/api/test");
expect(result).toEqual({ hello: "world" });
// 驗證url 正確組合 + 沒有 Content-TypeGET 無 body+ 沒有 Authorization
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toMatch(/\/api\/test$/);
expect(init.method).toBe("GET");
const headers = init.headers as Record<string, string>;
expect(headers["Content-Type"]).toBeUndefined();
expect(headers["Authorization"]).toBeUndefined();
});
it("POST 會 JSON serialize body 並加 Content-Type", async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ success: true, data: { id: "x" } }), {
status: 200,
}),
);
const result = await api.post<{ id: string }>("/api/items", { name: "a" });
expect(result).toEqual({ id: "x" });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.method).toBe("POST");
expect(init.body).toBe(JSON.stringify({ name: "a" }));
const headers = init.headers as Record<string, string>;
expect(headers["Content-Type"]).toBe("application/json");
});
it("204 No Content 時回傳 undefined", async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
const result = await api.del<undefined>("/api/items/1");
expect(result).toBeUndefined();
});
/* ------------------------------------------------------------------------ */
/* OF2BFF cookie session */
/* ------------------------------------------------------------------------ */
it("所有請求都帶 credentials: 'include'(讓 browser 自動攜帶 visiona_session cookie", async () => {
// 每次呼叫都產生新的 Response避免 body 已被消費的問題
fetchMock.mockImplementation(
() =>
new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
}),
);
await api.get("/api/auth/me");
await api.post("/api/auth/logout", {});
await api.del("/api/items/1");
expect(fetchMock.mock.calls.length).toBe(3);
for (const call of fetchMock.mock.calls) {
const init = call[1] as RequestInit;
expect(init.credentials).toBe("include");
}
});
it("OF2不再自動注入 Authorization headercookie 取代 Bearer token", async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ success: true, data: null }), { status: 200 }),
);
await api.get("/api/anything");
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers["Authorization"]).toBeUndefined();
});
it("caller 仍可傳入自訂 headers例如 X-Request-ID", async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ success: true, data: null }), { status: 200 }),
);
await api.get("/api/x", { headers: { "X-Request-ID": "abc" } });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers["X-Request-ID"]).toBe("abc");
});
/* ------------------------------------------------------------------------ */
/* Error handling */
/* ------------------------------------------------------------------------ */
it("失敗 envelope200 但 success=false→ ApiError", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
success: false,
error: { code: "NOT_IMPLEMENTED", message: "Dev only" },
}),
{ status: 200 },
),
);
await expect(api.post("/api/auth/login", {})).rejects.toMatchObject({
name: "ApiError",
code: "NOT_IMPLEMENTED",
message: "Dev only",
status: 200,
});
});
it("non-2xx 有 envelope → ApiError 帶 HTTP status + code", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
success: false,
error: { code: "UNAUTHORIZED", message: "Session 過期" },
}),
{ status: 401 },
),
);
try {
await api.get("/api/protected");
expect.fail("應該拋錯");
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(401);
expect((err as ApiError).code).toBe("UNAUTHORIZED");
}
});
it("OF2401 即使無 bodycode 也應為 UNAUTHORIZED讓 auth-store 能精準分支)", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 401 }));
try {
await api.get("/api/auth/me");
expect.fail("應該拋錯");
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(401);
expect((err as ApiError).code).toBe("UNAUTHORIZED");
}
});
it("non-2xx 沒 body純 HTTP status→ ApiError with INTERNAL_ERROR非 401", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 502 }));
await expect(api.get("/api/down")).rejects.toMatchObject({
name: "ApiError",
status: 502,
code: "INTERNAL_ERROR",
});
});
it("non-2xx 有 text body 但非 JSON → ApiError 帶訊息", async () => {
fetchMock.mockResolvedValue(new Response("Bad Gateway", { status: 502 }));
await expect(api.get("/api/down")).rejects.toMatchObject({
name: "ApiError",
status: 502,
message: "Bad Gateway",
});
});
it("fetch reject網路錯誤 / CORS 拒絕)→ NetworkError", async () => {
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
await expect(api.get("/api/x")).rejects.toBeInstanceOf(NetworkError);
});
it("2xx 但 JSON parse 失敗 → ParseError", async () => {
fetchMock.mockResolvedValue(
new Response("not json", {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(api.get("/api/x")).rejects.toBeInstanceOf(ParseError);
});
/* ------------------------------------------------------------------------ */
/* Timeout & Abort */
/* ------------------------------------------------------------------------ */
it("timeoutMs 觸發時拋 TimeoutError", async () => {
// 模擬 fetch 永遠 hang 直到 signal abort
fetchMock.mockImplementation((_url: string, init: RequestInit) => {
return new Promise((_resolve, reject) => {
const s = init.signal as AbortSignal;
s.addEventListener("abort", () => {
const e = new Error("aborted");
e.name = "AbortError";
reject(e);
});
});
});
await expect(
api.get("/api/slow", { timeoutMs: 50 }),
).rejects.toBeInstanceOf(TimeoutError);
});
it("caller 傳入 signal 且 abort → AbortError非 Timeout", async () => {
fetchMock.mockImplementation((_url: string, init: RequestInit) => {
return new Promise((_resolve, reject) => {
const s = init.signal as AbortSignal;
s.addEventListener("abort", () => {
const e = new Error("aborted");
e.name = "AbortError";
reject(e);
});
});
});
const ctrl = new AbortController();
const p = api.get("/api/x", { signal: ctrl.signal });
ctrl.abort();
await expect(p).rejects.toMatchObject({ name: "AbortError", code: "ABORTED" });
});
});

View File

@ -0,0 +1,491 @@
/**
* API Client visionA Cloud
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.2API client cookie session
* - `.autoflow/04-architecture/api/api-spec.md`REST API + envelope
* - `.autoflow/03-design/flows/flow-offline-handling.md` UI
* - `.autoflow/03-design/flows/flow-model-upload.md`Upload XHR progress
*
* OF2 / Phase 0.6 OIDC BFF
* 1. **Base URL **`NEXT_PUBLIC_API_BASE` hardcode
* 2. **Cookie sessionBFF Pattern** `credentials: 'include'`
* browser backend `visiona_session` HttpOnly cookie
* **frontend OIDC token**access_token / id_token backend
* 3. ** Authorization header** OF1 `setApiTokenGetter`
* auth-store token401 callerauth-store.hydrate / fetchMe
* UI user / /login
* 4. ****ApiError / NetworkError / TimeoutError / AbortError
* `error.code` UI api-spec §11
* 5. **Timeout 30s** hang UI
* 6. **Upload XHR**fetch ReadableStream upload
*
* origin dev frontend localhost:3000 backend localhost:3721
* - backend CORS `Access-Control-Allow-Credentials: true`
* - backend CORS `Access-Control-Allow-Origin: http://localhost:3000`
* ** `*`** cookie origin
* - fetch throw `TypeError: Failed to fetch` NetworkError
*/
import type { ApiEnvelope, ApiErrorShape, KnownErrorCode } from "@/types/api";
/* -------------------------------------------------------------------------- */
/* Env / Base URL */
/* -------------------------------------------------------------------------- */
/**
* API base URL
*
* Phase 0.7 stage deployment fix .autoflow/05-implementation/phase-0.7-frontend-fix.md
* 1. ****
* - `""` originbrowser origin
* - `https://api.example.com` URL origin cookie + CORS
* 2. ****undefined
* - Browser fallback origin prod / stage
* - SSR / Node fallback `http://localhost:3721` dev
* 3. trailing slash `//api/...`
*
* `??` `||`
* - = origin
* - login/page.tsx buildLoginUrl() 使 `??`
*
* stage env origin
* - stage nginx.stage.conf `/api/*` backendfrontend backend host:port
* - fallback `http://localhost:3721`build inline 使** PC 3721**
* server fetch UI
*/
export function getApiBaseUrl(): string {
const raw =
typeof process !== "undefined" ? process.env?.NEXT_PUBLIC_API_BASE : undefined;
if (typeof raw === "string") {
// 明確設值(含 "")— 直接使用;空字串走同 origin
return raw.replace(/\/+$/, "");
}
// 沒注入browser 端走同 originSSR / Node 端走 dev 預設 localhost
if (typeof window !== "undefined") {
return "";
}
return "http://localhost:3721";
}
/**
* WebSocket base URL
*
*
* - `NEXT_PUBLIC_WS_BASE`
* - API base URL `http://` `ws://`, `https://` `wss://`
*/
export function getWsBaseUrl(): string {
const explicit =
typeof process !== "undefined" ? process.env?.NEXT_PUBLIC_WS_BASE : undefined;
if (explicit) return explicit.replace(/\/+$/, "");
const api = getApiBaseUrl();
// Phase 0.7:當 api = "" (同 origin) 時,這裡回 "" — caller 需自行接 location.host 組成 wss URL
// 推導 http→ws / https→wss
if (api === "") return "";
return api.replace(/^http/, "ws");
}
/* -------------------------------------------------------------------------- */
/* Error classes */
/* -------------------------------------------------------------------------- */
/**
* API client
* caller `instanceof BaseApiClientError`
*/
export class BaseApiClientError extends Error {
readonly code: KnownErrorCode;
constructor(code: KnownErrorCode, message: string) {
super(message);
this.name = this.constructor.name;
this.code = code;
// 保留 V8 stack trace 起點Node/現代瀏覽器皆可安全呼叫)
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, this.constructor);
}
}
}
/**
* non-2xx
* - `status` HTTP status code
* - `code` envelope `error.code`api-spec §11
* - `details` envelope `details`
*
* UI
* try { await api.post(...) } catch (e) {
* if (e instanceof ApiError && e.code === "UNAUTHORIZED") goToLogin();
* }
*/
export class ApiError extends BaseApiClientError {
readonly status: number;
readonly details?: unknown;
constructor(status: number, error: ApiErrorShape) {
super((error.code as KnownErrorCode) ?? "INTERNAL_ERROR", error.message);
this.status = status;
this.details = error.details;
}
}
/** 無法連上伺服器DNS 解析失敗、CORS、伺服器拒連線等 fetch throw 的情境)。 */
export class NetworkError extends BaseApiClientError {
constructor(message = "Network error") {
super("NETWORK_ERROR", message);
}
}
/** 超過 timeout 時主動 abort 拋出。 */
export class TimeoutError extends BaseApiClientError {
constructor(message = "Request timed out") {
super("TIMEOUT", message);
}
}
/** caller 傳入的 AbortSignal 觸發時拋出(不算錯誤但用同一體系讓 caller 可分支)。 */
export class AbortError extends BaseApiClientError {
constructor(message = "Request aborted") {
super("ABORTED", message);
}
}
/** 回應 body 無法 JSON parse 時拋出。 */
export class ParseError extends BaseApiClientError {
constructor(message = "Invalid JSON response") {
super("PARSE_ERROR", message);
}
}
/* -------------------------------------------------------------------------- */
/* Core request */
/* -------------------------------------------------------------------------- */
export interface RequestOptions {
/** 額外 headers — 會覆蓋 default Content-Type */
headers?: Record<string, string>;
/** 非 GET 時的 body若傳 object 會自動 JSON.stringify */
body?: unknown;
/** 取消訊號 */
signal?: AbortSignal;
/** 覆寫 timeout毫秒預設 30s。傳 0 可關閉 timeout */
timeoutMs?: number;
}
const DEFAULT_TIMEOUT_MS = 30_000;
/** HTTP method — 集中 union 方便 `method` 的靜態檢查 */
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
function isJsonBody(body: unknown): boolean {
if (body === null || body === undefined) return false;
if (typeof body === "string") return false;
if (body instanceof FormData) return false;
if (body instanceof Blob) return false;
if (body instanceof ArrayBuffer) return false;
return true;
}
/**
* request export `api.get/post/...`
*
* `data` payload envelope
* 2xx envelope.success=false `ApiError`
*/
async function request<T>(
method: Method,
path: string,
options: RequestOptions = {},
): Promise<T> {
const url = `${getApiBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
// 組 headers
const headers: Record<string, string> = {};
const hasJsonBody = method !== "GET" && isJsonBody(options.body);
if (hasJsonBody) {
headers["Content-Type"] = "application/json";
}
// OF2不再注入 Authorization header認證走 cookiecredentials: 'include'
// caller 傳入的 headers 優先級最高
Object.assign(headers, options.headers ?? {});
// 組 body
let body: BodyInit | undefined;
if (method !== "GET" && options.body !== undefined && options.body !== null) {
if (hasJsonBody) {
body = JSON.stringify(options.body);
} else {
body = options.body as BodyInit;
}
}
// Timeout 與 signal 合併
const timeoutMs =
options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs;
const timeoutCtrl = new AbortController();
const timeoutId =
timeoutMs > 0
? setTimeout(() => timeoutCtrl.abort("timeout"), timeoutMs)
: null;
// 若 caller 傳入 signal串接起來任一 abort 皆觸發
const signal = mergeSignals(options.signal, timeoutCtrl.signal);
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body,
signal,
// OF2BFF 模式 — browser 自動帶 visiona_session cookieHttpOnly
// 跨 origin 時 backend CORS 必須允許 credentials同 origin 時無影響
credentials: "include",
});
} catch (err) {
if (timeoutId) clearTimeout(timeoutId);
// fetch throw 通常是 TypeError (網路) 或 DOMException (abort)
if (isAbortLikeError(err)) {
if (timeoutCtrl.signal.aborted) {
throw new TimeoutError(`Request to ${path} timed out after ${timeoutMs}ms`);
}
throw new AbortError(`Request to ${path} was aborted`);
}
throw new NetworkError(
`Failed to reach ${url}: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (timeoutId) clearTimeout(timeoutId);
// 204 No Content — 回傳 undefined 當 Tcaller 要自己處理)
if (res.status === 204) {
return undefined as T;
}
// 讀 body — 一律嘗試 JSON parse失敗則丟 ParseError
let payload: ApiEnvelope<T> | Partial<ApiEnvelope<T>> | null = null;
const text = await res.text();
if (text.length > 0) {
try {
payload = JSON.parse(text);
} catch {
// 若是 non-2xx 且 body 不是 JSON仍要給出有意義的 ApiError
if (!res.ok) {
throw new ApiError(res.status, {
code: "INTERNAL_ERROR",
message: text.slice(0, 200) || `HTTP ${res.status}`,
});
}
throw new ParseError(`Failed to parse JSON from ${path}`);
}
}
// Envelope 判讀
if (!res.ok) {
const err: ApiErrorShape =
(payload && "error" in payload && payload.error) || {
code: res.status === 401 ? "UNAUTHORIZED" : "INTERNAL_ERROR",
message: `HTTP ${res.status}`,
};
throw new ApiError(res.status, err);
}
if (payload && typeof payload === "object" && "success" in payload) {
if (payload.success === false && "error" in payload && payload.error) {
throw new ApiError(res.status, payload.error);
}
if (payload.success === true && "data" in payload) {
return payload.data as T;
}
// success 欄位為 true 但無 data例如 logout→ undefined as T
return undefined as T;
}
// 後端未包 envelope 的情境(例如 /storage 直接回二進位)
// — 雛形只預期 JSON若此處出現表示 API 契約不一致
if (process.env.NODE_ENV !== "production") {
console.warn(
`[api] Non-envelope response at ${method} ${path}. Server is expected to return ApiEnvelope<T>.`,
);
}
return (payload as unknown) as T;
}
/**
* AbortSignal abort
* `AbortSignal.any`
*/
function mergeSignals(
user: AbortSignal | undefined,
timeout: AbortSignal,
): AbortSignal {
if (!user) return timeout;
if (user.aborted) return user;
if (timeout.aborted) return timeout;
const ctrl = new AbortController();
const onAbort = () => ctrl.abort();
user.addEventListener("abort", onAbort, { once: true });
timeout.addEventListener("abort", onAbort, { once: true });
return ctrl.signal;
}
/** 判斷是否為 abort 類型的錯誤fetch throw DOMException 或 Error "AbortError" */
function isAbortLikeError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
// node fetch / undici 偶爾會回 "The operation was aborted"
if (/aborted/i.test(err.message)) return true;
}
return false;
}
/* -------------------------------------------------------------------------- */
/* Upload with progress (XHR) */
/* -------------------------------------------------------------------------- */
export interface UploadOptions {
/** 進度 callback百分比 0~100、已傳 bytes、總 bytes */
onProgress?: (progress: { percent: number; loaded: number; total: number }) => void;
/** 額外 headers例如 presigned URL 需要的 x-amz-meta-* */
headers?: Record<string, string>;
/** HTTP method — presigned URL 通常是 PUT可覆寫為 POST */
method?: "PUT" | "POST";
/** 取消訊號ESC 對話框 / 使用者按「取消」) */
signal?: AbortSignal;
/** 若為 true請求 URL 視為絕對路徑(用於 presigned PUT 直接打 S3 */
absolute?: boolean;
}
/**
*
*
*
* 1. PUT `/api/media/upload/image` api-server
* 2. PUT presigned URLS3 / LocalFS `absolute: true`
*
*
* - 使 XHR `fetch` upload stream progress
* - OF2 absolute= backend `withCredentials = true`
* browser visiona_session cookieabsolutepresigned URL S3
* - ApiError / NetworkError / AbortError / TimeoutError
* - storage `etag`storage ETag header
*/
export function upload(
path: string,
file: Blob,
options: UploadOptions = {},
): Promise<{ etag: string | null; status: number }> {
const method = options.method ?? "PUT";
const url = options.absolute ? path : `${getApiBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url, true);
// OF2打自家 backend 時帶 cookieBFF sessionpresigned URL 直送 S3 不帶
if (!options.absolute) {
xhr.withCredentials = true;
}
// caller 的 headers絕對 URL 場景必填,例如 `Content-Type: application/octet-stream`
if (options.headers) {
for (const [k, v] of Object.entries(options.headers)) {
xhr.setRequestHeader(k, v);
}
}
// Progress
if (options.onProgress) {
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
options.onProgress!({
loaded: ev.loaded,
total: ev.total,
percent: Math.min(100, Math.round((ev.loaded / ev.total) * 100)),
});
}
};
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
// 取 ETag不同 storage 實作可能叫 ETag / etag
const etag =
xhr.getResponseHeader("ETag") ?? xhr.getResponseHeader("etag") ?? null;
resolve({ etag: etag ? etag.replace(/"/g, "") : null, status: xhr.status });
} else {
// 嘗試 parse envelope否則用純文字當 message
let errShape: ApiErrorShape = {
code: "INTERNAL_ERROR",
message: `Upload failed: HTTP ${xhr.status}`,
};
try {
const parsed = JSON.parse(xhr.responseText);
if (parsed && typeof parsed === "object" && "error" in parsed && parsed.error) {
errShape = parsed.error as ApiErrorShape;
}
} catch {
// 非 JSON bodyS3 通常回 XML保留預設訊息
}
reject(new ApiError(xhr.status, errShape));
}
};
xhr.onerror = () => {
reject(new NetworkError(`Upload to ${url} failed (network error)`));
};
xhr.ontimeout = () => {
reject(new TimeoutError(`Upload to ${url} timed out`));
};
// 支援 caller signal
if (options.signal) {
if (options.signal.aborted) {
xhr.abort();
reject(new AbortError());
return;
}
options.signal.addEventListener(
"abort",
() => {
xhr.abort();
reject(new AbortError());
},
{ once: true },
);
}
xhr.send(file);
});
}
/* -------------------------------------------------------------------------- */
/* Public API */
/* -------------------------------------------------------------------------- */
/**
* visionA Cloud API client
*
*
* - base URL
* - `credentials: 'include'`BFF cookie session
* - JSON serialize / deserialize
* - envelope `data` throw `ApiError`
*/
export const api = {
get: <T>(path: string, options?: Omit<RequestOptions, "body">) =>
request<T>("GET", path, options),
post: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "body">) =>
request<T>("POST", path, { ...options, body }),
put: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "body">) =>
request<T>("PUT", path, { ...options, body }),
patch: <T>(path: string, body?: unknown, options?: Omit<RequestOptions, "body">) =>
request<T>("PATCH", path, { ...options, body }),
del: <T>(path: string, options?: Omit<RequestOptions, "body">) =>
request<T>("DELETE", path, options),
upload,
};
export type Api = typeof api;

View File

@ -0,0 +1,91 @@
/**
* i18n Context / useT()
*
*
* 1. LocaleProvider useT()
* 2. locale useT()
* 3. key fallback key console.warn
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider, useLocale, useT } from "./context";
function Probe() {
const t = useT();
const { locale, setLocale } = useLocale();
return (
<div>
<span data-testid="locale">{locale}</span>
<span data-testid="title">{t("app.title")}</span>
{/* tagline ...vs Extending...
locale F2 Minor #1 */}
<span data-testid="tagline">{t("app.tagline")}</span>
<span data-testid="missing">{t("this.key.does.not.exist")}</span>
<button type="button" onClick={() => setLocale("en")} data-testid="to-en">
en
</button>
<button type="button" onClick={() => setLocale("zh-Hant")} data-testid="to-zh">
zh-Hant
</button>
</div>
);
}
describe("LocaleProvider + useT", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// 攔截 console.warn非 production 模式下 useT 會 warn 缺漏的 key
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it("預設以 zh-Hant 渲染", () => {
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
expect(screen.getByTestId("locale").textContent).toBe("zh-Hant");
expect(screen.getByTestId("title").textContent).toBe("visionA Cloud");
});
it("切換到 en 後 useT() 回傳英文字串", () => {
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
// 切換前先驗預設 zh-Hant tagline
expect(screen.getByTestId("tagline").textContent).toBe(
"將 local-tool 的使用體驗延伸到雲端",
);
act(() => {
screen.getByTestId("to-en").click();
});
expect(screen.getByTestId("locale").textContent).toBe("en");
// 斷言 tagline 已切換為英文 — 不只 locale state 切換,實際翻譯字串也要跟著改
// app.title 中英文皆為 "visionA Cloud",不適合當觀測點,故改驗 tagline
expect(screen.getByTestId("tagline").textContent).toBe(
"Extending the local-tool experience to the cloud",
);
});
it("找不到 key 時回傳 key 本身並發出 warning", () => {
render(
<LocaleProvider>
<Probe />
</LocaleProvider>,
);
expect(screen.getByTestId("missing").textContent).toBe("this.key.does.not.exist");
expect(warnSpy).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,92 @@
"use client";
/**
* i18n React Context visionA Cloud
*
*
* - <LocaleProvider> app root layout locale
* - useLocale() locale
* - useT() `t(key)` locale re-render
*
*
* 1. DEFAULT_LOCALE SSR Client render hydration mismatch
* 2. localStorage <LocaleSync /> mount sync.tsx
* 3. `t()` key key console.warn
*/
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { dictionaries } from "./index";
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "./types";
/** Context 內容型別:當前 locale + setter。 */
interface LocaleContextValue {
locale: Locale;
setLocale: (next: Locale) => void;
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
interface LocaleProviderProps {
/** 初始 localeSSR 情境下建議使用 DEFAULT_LOCALE 避免 hydration mismatch。 */
initialLocale?: Locale;
children: React.ReactNode;
}
/**
* locale provider root layout ThemeProvider
*/
export function LocaleProvider({ initialLocale = DEFAULT_LOCALE, children }: LocaleProviderProps) {
const [locale, setLocaleState] = useState<Locale>(initialLocale);
/** 切換 locale 同時寫入 localStorage下次造訪自動恢復。 */
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
} catch {
// localStorage 可能被使用者停用Safari 隱私模式),靜默忽略即可
}
}
}, []);
const value = useMemo<LocaleContextValue>(() => ({ locale, setLocale }), [locale, setLocale]);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}
/**
* locale setter
* <LocaleProvider> 使使
*/
export function useLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) {
throw new Error("useLocale 必須在 <LocaleProvider> 內使用");
}
return ctx;
}
/** `t()` 函式型別:輸入 key 回傳翻譯後字串。 */
export type TranslateFn = (key: string) => string;
/**
* locale `t()`
* - key key warningproduction log
* - locale useMemo `t()` re-render
*/
export function useT(): TranslateFn {
const { locale } = useLocale();
return useMemo<TranslateFn>(() => {
const dict = dictionaries[locale];
return (key: string) => {
const value = dict[key];
if (typeof value === "string") return value;
if (process.env.NODE_ENV !== "production") {
// 漏譯是開發時必須注意的警示production 模式靜默
console.warn(`[i18n] 缺少翻譯 key${key}」(locale=${locale})`);
}
return key;
};
}, [locale]);
}

View File

@ -0,0 +1,349 @@
/**
* English dictionary visionA Cloud
*
* Must contain the exact same set of keys as zh-Hant.ts (the unit test enforces this).
*/
import type { Dictionary } from "../types";
export const en: Dictionary = {
// ── App ──
"app.title": "visionA Cloud",
"app.tagline": "Extending the local-tool experience to the cloud",
// ── Common ──
"common.loading": "Loading…",
"common.error": "Something went wrong",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.save": "Save",
"common.close": "Close",
"common.retry": "Retry",
"common.view": "View",
"common.manage": "Manage",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
"common.delete": "Delete",
"common.edit": "Edit",
"common.back": "Back",
"common.na": "—",
// ── Sidebar navigation ──
"nav.dashboard": "Dashboard",
"nav.devices": "Devices",
"nav.models": "Models",
"nav.workspace": "Workspace",
"nav.clusters": "Clusters",
"nav.settings": "Settings",
// ── Prototype banner ──
// Phase 0.7 stage deployment fix: OIDC is wired to Innovedus Account Center; remove "no real auth" wording.
"banner.prototype":
"🚧 Prototype build · Member Center login enabled, but some flows are UI mockups; sample data only.",
"banner.prototype.short": "🚧 Prototype (demo)",
"banner.prototype.ariaLabel": "Prototype notice",
// ── Header ──
"header.toggleTheme": "Toggle theme",
"header.toggleLocale": "Toggle language",
"header.userMenu.open": "Open user menu",
"header.userMenu.profile": "Account settings",
"header.userMenu.logout": "Sign out",
"header.userMenu.fallbackName": "User",
"header.breadcrumb.home": "Home",
// ── Tunnel / remote status ──
"tunnel.status.online": "Connected",
"tunnel.status.offline": "Offline",
"tunnel.status.reconnecting": "Reconnecting",
"tunnel.rttLabel": "RTT {rtt}ms",
// ── Theme toggle ──
"theme.light": "Light",
"theme.dark": "Dark",
"theme.system": "System",
// ── Auth (Phase 0.6 OIDC redirect mode) ──
// Design notes:
// - All email/password form keys removed; visionA delegates auth to Innovedus Member Center
// - Shared sign-in/out copy lives under `auth.action.*` so non-form callsites (e.g. header
// dropdown) no longer borrow `auth.login.submit`
"auth.action.signIn": "Sign in",
"auth.action.signOut": "Sign out",
// Login page
"auth.login.title": "Sign in to visionA Cloud",
"auth.login.subtitle": "Welcome back to visionA Cloud",
"auth.login.welcomeBack": "Welcome back",
"auth.login.signInWithMC": "Sign in with your Innovedus account",
"auth.login.button": "Sign in",
"auth.login.noAccount": "Don't have an account?",
"auth.login.registerLink": "Register now",
"auth.login.prototypeHint":
"Phase 0.6 prototype — sign-in redirects to the Innovedus Account Center for verification.",
// Register page (Phase 0.6: visionA no longer hosts a sign-up form; redirects to Member Center)
"auth.register.title": "Register for visionA",
"auth.register.description":
"visionA uses the Innovedus unified account system.",
"auth.register.howTo":
"Please register at the Innovedus Account Center. Once done you can sign in to visionA.",
"auth.register.button": "Go to Innovedus Account Center",
"auth.register.disabledHint":
"Innovedus Account Center URL is not configured. Please contact the administrator.",
"auth.register.alreadyHaveAccount": "Already have an account?",
"auth.register.loginLink": "Sign in",
// ── Home / Dashboard ──
"home.welcome": "Welcome back, {name}",
"home.placeholder":
"This will be the Dashboard (StatCard / ActivityTimeline / ConnectedDevicesList); F6 will port it from local-tool.",
"dashboard.title": "Dashboard",
"dashboard.subtitle": "Manage your cloud-based Edge AI assets",
"dashboard.models": "Models",
"dashboard.devices": "Devices",
"dashboard.connected": "Online devices",
"dashboard.flashes": "Flashes",
"dashboard.connectedDevices": "Online devices",
"dashboard.noConnectedDevices":
"No devices are online. Pair a Kneron device to start cloud inference.",
"dashboard.recentActivity": "Recent activity",
"dashboard.noActivity":
"Nothing here yet. Activity appears after pairing, uploads, or inference runs.",
"dashboard.quickActions": "Quick actions",
"dashboard.browseModels": "Browse models",
"dashboard.manageDevices": "Manage devices",
"dashboard.uploadModel": "Upload model",
"dashboard.pairDevice": "Pair device",
"dashboard.empty.title": "No devices yet",
"dashboard.empty.description":
"Pair your first Kneron device to start running inference from anywhere.",
"dashboard.empty.action": "Pair a device",
"dashboard.activity.justNow": "just now",
"dashboard.activity.minutesAgo": "{n} minutes ago",
"dashboard.activity.hoursAgo": "{n} hours ago",
"dashboard.activity.daysAgo": "{n} days ago",
// ── Devices ──
"devices.title": "Devices",
"devices.subtitle": "Manage your Edge AI devices",
"devices.type": "Type",
"devices.firmware": "Firmware",
"devices.flashedModel": "Flashed model",
"devices.openWorkspace": "Open workspace",
"devices.addMore": "Pair a new device",
"devices.pairAction": "Pair a new device",
"devices.empty.title": "No devices paired yet",
"devices.empty.description":
"Run local agent on your computer and complete pairing to access your Kneron devices from anywhere.",
"devices.empty.action": "Pair your first device",
"devices.empty.secondaryAction": "How pairing works",
"devices.detail.id": "ID",
"devices.detail.type": "Type",
"devices.detail.firmware": "Firmware",
"devices.detail.port": "Port",
"devices.detail.deviceInfo": "Device info",
"devices.detail.modelStatus": "Model status",
"devices.detail.readyForInference": "Ready for inference",
"devices.detail.noModelFlashed": "No model has been flashed",
"devices.detail.pairedAt": "Paired at",
"devices.detail.hostName": "Host",
"devices.detail.lastSeen": "Last seen",
"devices.detail.offlineBanner.title": "This device is offline",
"devices.detail.offlineBanner.description":
"Some actions are unavailable until local agent reconnects.",
"devices.status.detected": "Detected",
"devices.status.connecting": "Connecting",
"devices.status.connected": "Connected",
"devices.status.flashing": "Flashing",
"devices.status.inferencing": "Inferencing",
"devices.status.error": "Error",
"devices.status.disconnected": "Disconnected",
// ── Remote Device Badge ──
"remote.status.online": "Online",
"remote.status.offline": "Offline",
"remote.status.reconnecting": "Reconnecting",
"remote.status.error": "Connection error",
"remote.status.unknown": "Unknown",
"remote.lastSeenNever": "Never connected",
"remote.lastSeen.justNow": "just now",
"remote.lastSeen.minutesAgo": "{n} min ago",
"remote.lastSeen.hoursAgo": "{n} h ago",
// ── Models ──
"models.title": "Models",
"models.subtitle": "Manage Kneron models on the cloud",
"models.size": "Size",
"models.createdAt": "Created",
"models.status.uploading": "Uploading",
"models.status.scanning": "Scanning",
"models.status.ready": "Ready",
"models.status.rejected": "Rejected",
"models.source.uploaded": "Uploaded",
"models.source.preset": "Preset",
"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.detail.description": "Description",
"models.detail.version": "Version",
"models.detail.checksum": "Checksum",
"models.detail.supportedChips": "Supported chips",
"models.detail.deployToDevice": "Deploy to device",
// ── Model Upload Dialog ──
"models.upload.button": "Upload model",
"models.upload.dialog.title": "Upload model",
"models.upload.dropzone.hint": "Format: .nef · Max 100 MB",
"models.upload.selectedFile": "Choose file",
"models.upload.field.name": "Model name",
"models.upload.field.version": "Version",
"models.upload.field.notes": "Notes (optional)",
"models.upload.field.targetChip": "Target chip",
"models.upload.action.cancel": "Cancel",
"models.upload.action.start": "Start upload",
"models.upload.action.remove": "Remove",
"models.upload.uploading.title": "Uploading",
"models.upload.uploading.hint": "Do not close this window or navigate away.",
"models.upload.success.title": "Upload complete",
"models.upload.success.scanHint":
"The model is entering safety scan; it will be available once ready.",
"models.upload.error.invalidType":
"Only .nef files are supported, got {type}.",
"models.upload.error.tooLarge":
"File too large ({size}); max allowed is 100 MB.",
"models.upload.error.requiredField": "{field} is required.",
"models.upload.error.urlFailed": "Server is busy, please try again.",
"models.upload.error.networkLost": "Network lost, upload paused.",
"models.upload.toast.uploaded": "Model \"{name}\" uploaded.",
// ── Workspace ──
"workspace.title": "Workspace",
"workspace.subtitle": "Select an online device to start inference",
"workspace.empty.title": "No devices are online",
"workspace.empty.description":
"Pair a device and make sure the local agent is connected to the cloud.",
"workspace.empty.action": "Go to devices",
"workspace.header.backToDevices": "Back to devices",
"workspace.header.title": "Workspace",
"workspace.inference.start": "Start inference",
"workspace.inference.stop": "Stop inference",
"workspace.placeholder.cameraComingSoon":
"Camera inference preview — F8 will wire up the MJPEG stream through the tunnel from local agent.",
"workspace.offline.title": "Device went offline",
"workspace.offline.description":
"The connection to {deviceName} was lost; inference has been stopped.",
"workspace.offline.backToList": "Back to devices",
"workspace.tabs.camera": "Camera",
"workspace.tabs.image": "Image (Phase 1)",
"workspace.tabs.video": "Video (Phase 1)",
"workspace.tabs.batch": "Batch (Phase 1)",
// ── Settings ──
"settings.title": "Settings",
"settings.subtitle": "Manage preferences and cloud endpoints",
"settings.tabs.general": "General",
"settings.tabs.advanced": "Advanced",
"settings.general.title": "Preferences",
"settings.general.language": "Language",
"settings.general.theme": "Theme",
"settings.general.themeHint":
"Automatically follow your system light / dark mode.",
"settings.advanced.title": "Cloud endpoints",
"settings.advanced.apiEndpoint": "API endpoint",
"settings.advanced.apiEndpointHint":
"Normal users should not change this; developers can switch to staging or localhost.",
"settings.advanced.apiUrl": "Current API URL",
"settings.advanced.wsUrl": "Current WebSocket URL",
"settings.advanced.about": "About",
"settings.advanced.version": "Version",
"settings.advanced.platform": "Platform",
// ── Pairing (F7) ──
"pairing.title": "Pair a new device",
"pairing.subtitle":
"Connect your Kneron device to the cloud so you can operate it from anywhere.",
"pairing.token.title": "Your pairing token",
"pairing.step1.description":
"Copy the token below and paste it into your local agent within 15 minutes.",
"pairing.copy": "Copy",
"pairing.copied": "Copied",
"pairing.regenerate": "Regenerate",
"pairing.timeRemaining": "{time} remaining",
"pairing.generatedAt": "Generated at {time}",
"pairing.token.expired.label": "This token has expired — please regenerate.",
"pairing.regenerateConfirm.title": "Regenerate token?",
"pairing.regenerateConfirm.description":
"The old token will be invalidated immediately; the new one is valid for 15 minutes.",
"pairing.security.warning":
"This token is valid for 15 minutes — complete pairing now.",
"pairing.security.oneTime":
"Tokens are single-use and expire automatically after pairing.",
"pairing.toast.copied": "Token copied — valid for 15 minutes.",
"pairing.toast.generateFailed": "Could not generate token — please retry.",
"pairing.toast.expiringSoon":
"Token expiring soon — complete pairing or regenerate.",
"pairing.toast.pairedSuccess": "Device {deviceName} paired successfully.",
"pairing.toast.cliCopied": "CLI command copied.",
"pairing.device.unknown": "Unknown device",
"pairing.cli.title": "CLI example",
"pairing.cli.description":
"Start local agent on your computer and pass the token to the --relay-token flag.",
"pairing.cli.copy": "Copy command",
"pairing.cli.hint":
"Once local agent connects to the cloud, this page detects it and forwards you to the device list.",
"pairing.step3.waiting": "Waiting for local agent to connect…",
"pairing.step3.elapsed": "Elapsed {time} (max 3 minutes)",
"pairing.step3.hints.running": "Confirm local agent is running",
"pairing.step3.hints.token":
"Confirm the token was pasted without missing or extra characters",
"pairing.step3.hints.network":
"Confirm your network can reach the cloud endpoint",
"pairing.step3.success": "Connected!",
"pairing.step3.success.detected": "Detected device",
"pairing.step3.failure.timeout": "Connection timeout",
"pairing.step3.failure.reason":
"No local agent connection within 3 minutes — agent may not be running.",
"pairing.step3.failure.retry": "Check again",
// ── Account (Phase 0.6 OIDC) ──
// Design notes:
// - Profile is read-only; edits go through the Innovedus Account Center
// - "Delete account" stays as a disabled stub; copy directs users to Member Center
"account.title": "Account settings",
"account.subtitle": "Profile is managed by the Innovedus Account Center",
"account.profile.title": "Profile",
"account.profile.userId": "User ID",
"account.profile.email": "Email",
"account.profile.name": "Display name",
"account.profile.namePlaceholder": "—",
"account.profile.managedBy":
"Your profile is managed by the Innovedus Account Center. To make changes, please go to the Innovedus Account Center.",
"account.profile.editAtMemberCenter": "Open Innovedus Account Center",
"account.profile.editDisabledHint":
"Innovedus Account Center URL is not configured. Please contact the administrator.",
"account.session.title": "Session",
"account.session.description":
"Signing out returns you to the login page. You will need to sign in again through the Innovedus Account Center.",
"account.danger.title": "Danger zone",
"account.danger.description":
"Account deletion is handled at the Innovedus Account Center; visionA does not perform deletion directly.",
"account.danger.deleteAccount": "Delete account",
"account.danger.deleteAccount.tooltip":
"Please handle this at the Innovedus Account Center.",
// ── Clusters (F7 stub) ──
"clusters.title": "Clusters",
"clusters.subtitle":
"Combine multiple Kneron devices into a parallel inference cluster.",
"clusters.create": "Create cluster",
"clusters.empty.title": "Cluster inference — coming in Phase 1",
"clusters.empty.description":
"Phase 1 will port the POCs multi-device parallel inference and degrade handling to the cloud.",
"clusters.phase1Badge": "Coming in Phase 1",
"clusters.phase1Toast": "Cluster creation arrives in Phase 1.",
};

View File

@ -0,0 +1,337 @@
/**
* visionA Cloud
*
*
* - key`nav.dashboard` key en.ts key
* - 使 + 使
* - F4Sidebar/HeaderF5auth/sessionF6 Dashboard / Devices / Models / Workspace / Settings
*/
import type { Dictionary } from "../types";
export const zhHant: Dictionary = {
// ── 應用 ──
"app.title": "visionA Cloud",
"app.tagline": "將 local-tool 的使用體驗延伸到雲端",
// ── 通用 ──
"common.loading": "載入中…",
"common.error": "發生錯誤",
"common.cancel": "取消",
"common.confirm": "確認",
"common.save": "儲存",
"common.close": "關閉",
"common.retry": "重試",
"common.view": "檢視",
"common.manage": "管理",
"common.connect": "連接",
"common.disconnect": "中斷連線",
"common.delete": "刪除",
"common.edit": "編輯",
"common.back": "返回",
"common.na": "—",
// ── 側邊導航 ──
"nav.dashboard": "儀表板",
"nav.devices": "裝置",
"nav.models": "模型",
"nav.workspace": "推論工作區",
"nav.clusters": "叢集",
"nav.settings": "設定",
// ── 雛形階段 banner ──
// Phase 0.7 stage deployment fixOIDC 已接 Innovedus 帳號中心,文案不再寫「未實作身分驗證」
"banner.prototype":
"🚧 雛形版本 · 已接 Innovedus 帳號中心,但部分功能仍為 UI 示意;資料為示範資料",
"banner.prototype.short": "🚧 雛形版本demo",
"banner.prototype.ariaLabel": "雛形版本提示",
// ── Header ──
"header.toggleTheme": "切換主題",
"header.toggleLocale": "切換語言",
"header.userMenu.open": "開啟使用者選單",
"header.userMenu.profile": "帳號設定",
"header.userMenu.logout": "登出",
"header.userMenu.fallbackName": "使用者",
"header.breadcrumb.home": "首頁",
// ── Tunnel / 遠端狀態(燈號) ──
"tunnel.status.online": "已連線",
"tunnel.status.offline": "離線",
"tunnel.status.reconnecting": "重新連線中",
"tunnel.rttLabel": "延遲 {rtt}ms",
// ── 主題切換next-themes ──
"theme.light": "淺色",
"theme.dark": "深色",
"theme.system": "跟隨系統",
// ── 認證Phase 0.6 OIDC redirect 模式) ──
// 設計重點:
// - 所有 email/password 表單時代的 keyauth.login.email/password/submit/...
// auth.register.email/password/...)已移除,改用 Member Center 統一帳號
// - 共用「登入 / 登出」字串收斂為 auth.action.signIn / auth.action.signOut
// 避免 header dropdown 之類的非表單元件再借用 auth.login.submit
"auth.action.signIn": "登入",
"auth.action.signOut": "登出",
// Login 頁
"auth.login.title": "登入 visionA Cloud",
"auth.login.subtitle": "歡迎回到 visionA Cloud",
"auth.login.welcomeBack": "歡迎回來",
"auth.login.signInWithMC": "使用您的 Innovedus 帳號登入",
"auth.login.button": "登入",
"auth.login.noAccount": "還沒有帳號?",
"auth.login.registerLink": "前往註冊",
"auth.login.prototypeHint":
"Phase 0.6 雛形 — 登入會跳轉到 Innovedus 帳號中心完成驗證",
// Register 頁Phase 0.6visionA 不再自有註冊表單,改導向 Member Center
"auth.register.title": "註冊 visionA",
"auth.register.description":
"visionA 使用 Innovedus 統一帳號系統。",
"auth.register.howTo":
"請至 Innovedus 帳號中心註冊,註冊完成後即可回到 visionA 登入。",
"auth.register.button": "前往 Innovedus 帳號中心",
"auth.register.disabledHint":
"尚未設定 Innovedus 帳號中心 URL請聯絡系統管理員。",
"auth.register.alreadyHaveAccount": "已有帳號?",
"auth.register.loginLink": "登入",
// ── 首頁 / Dashboard ──
"home.welcome": "歡迎回來,{name}",
"home.placeholder":
"這裡之後會是 DashboardStatCard / ActivityTimeline / ConnectedDevicesListF6 會從 local-tool 搬入。",
"dashboard.title": "儀表板",
"dashboard.subtitle": "管理你的雲端 Edge AI 資源",
"dashboard.models": "模型",
"dashboard.devices": "裝置",
"dashboard.connected": "線上裝置",
"dashboard.flashes": "已燒錄次數",
"dashboard.connectedDevices": "線上裝置",
"dashboard.noConnectedDevices": "目前沒有裝置線上。配對一台 Kneron 裝置開始雲端推論。",
"dashboard.recentActivity": "近期活動",
"dashboard.noActivity": "還沒有任何活動。配對裝置、上傳模型或跑一次推論後就會出現。",
"dashboard.quickActions": "快速操作",
"dashboard.browseModels": "瀏覽模型",
"dashboard.manageDevices": "管理裝置",
"dashboard.uploadModel": "上傳模型",
"dashboard.pairDevice": "配對裝置",
"dashboard.empty.title": "還沒有任何裝置",
"dashboard.empty.description": "配對你的第一台 Kneron 裝置,開始雲端推論之旅",
"dashboard.empty.action": "配對裝置",
"dashboard.activity.justNow": "剛剛",
"dashboard.activity.minutesAgo": "{n} 分鐘前",
"dashboard.activity.hoursAgo": "{n} 小時前",
"dashboard.activity.daysAgo": "{n} 天前",
// ── Devices ──
"devices.title": "裝置",
"devices.subtitle": "管理你的 Edge AI 裝置",
"devices.type": "類型",
"devices.firmware": "韌體",
"devices.flashedModel": "已燒錄模型",
"devices.openWorkspace": "開啟工作區",
"devices.addMore": "配對新裝置",
"devices.pairAction": "配對新裝置",
"devices.empty.title": "還沒有配對的裝置",
"devices.empty.description":
"在你的電腦上執行 local agent 並完成配對,就能從任何地方存取你的 Kneron 裝置",
"devices.empty.action": "配對第一台裝置",
"devices.empty.secondaryAction": "查看配對說明",
"devices.detail.id": "ID",
"devices.detail.type": "類型",
"devices.detail.firmware": "韌體",
"devices.detail.port": "連接埠",
"devices.detail.deviceInfo": "裝置資訊",
"devices.detail.modelStatus": "模型狀態",
"devices.detail.readyForInference": "已就緒,可開始推論",
"devices.detail.noModelFlashed": "尚未燒錄任何模型",
"devices.detail.pairedAt": "配對時間",
"devices.detail.hostName": "所在電腦",
"devices.detail.lastSeen": "最後心跳",
"devices.detail.offlineBanner.title": "此裝置目前離線",
"devices.detail.offlineBanner.description":
"部分操作無法使用,待 local agent 重新連線後自動恢復",
"devices.status.detected": "已偵測",
"devices.status.connecting": "連線中",
"devices.status.connected": "已連線",
"devices.status.flashing": "燒錄中",
"devices.status.inferencing": "推論中",
"devices.status.error": "錯誤",
"devices.status.disconnected": "未連接",
// ── Remote Device Badge雲端 tunnel 狀態) ──
"remote.status.online": "在線",
"remote.status.offline": "離線",
"remote.status.reconnecting": "重新連線中",
"remote.status.error": "連線錯誤",
"remote.status.unknown": "未確認",
"remote.lastSeenNever": "從未連線",
"remote.lastSeen.justNow": "剛剛",
"remote.lastSeen.minutesAgo": "{n} 分鐘前",
"remote.lastSeen.hoursAgo": "{n} 小時前",
// ── Models ──
"models.title": "模型庫",
"models.subtitle": "管理雲端上的 Kneron 模型",
"models.size": "檔案大小",
"models.createdAt": "建立時間",
"models.status.uploading": "上傳中",
"models.status.scanning": "掃描中",
"models.status.ready": "可用",
"models.status.rejected": "檢測失敗",
"models.source.uploaded": "自行上傳",
"models.source.preset": "預設",
"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.detail.description": "說明",
"models.detail.version": "版本",
"models.detail.checksum": "校驗碼",
"models.detail.supportedChips": "支援晶片",
"models.detail.deployToDevice": "部署至裝置",
// ── Model Upload Dialogflow-model-upload ──
"models.upload.button": "上傳模型",
"models.upload.dialog.title": "上傳模型",
"models.upload.dropzone.hint": "支援格式:.nef · 最大 100 MB",
"models.upload.selectedFile": "選擇檔案",
"models.upload.field.name": "模型名稱",
"models.upload.field.version": "版本",
"models.upload.field.notes": "備註(選填)",
"models.upload.field.targetChip": "目標晶片",
"models.upload.action.cancel": "取消",
"models.upload.action.start": "開始上傳",
"models.upload.action.remove": "移除",
"models.upload.uploading.title": "上傳中",
"models.upload.uploading.hint": "請勿關閉此視窗或導航到其他頁面",
"models.upload.success.title": "上傳完成",
"models.upload.success.scanHint": "模型即將進入安全掃描,完成後可燒錄",
"models.upload.error.invalidType": "只支援 .nef 檔案,你選的是 {type}",
"models.upload.error.tooLarge": "檔案太大({size}),最大允許 100 MB",
"models.upload.error.requiredField": "{field} 為必填",
"models.upload.error.urlFailed": "伺服器忙碌,請稍後再試",
"models.upload.error.networkLost": "網路中斷,上傳已暫停",
"models.upload.toast.uploaded": "模型「{name}」已上傳",
// ── Workspace ──
"workspace.title": "推論工作區",
"workspace.subtitle": "選擇已線上的裝置開始推論",
"workspace.empty.title": "目前沒有線上裝置",
"workspace.empty.description": "請先配對並確認 local agent 已連上雲端",
"workspace.empty.action": "前往裝置管理",
"workspace.header.backToDevices": "返回裝置",
"workspace.header.title": "工作區",
"workspace.inference.start": "開始推論",
"workspace.inference.stop": "停止推論",
"workspace.placeholder.cameraComingSoon":
"Camera 推論介面預覽 — F8 會接上 MJPEG stream透過 tunnel 從 local agent 中繼)",
"workspace.offline.title": "裝置已離線",
"workspace.offline.description": "與 {deviceName} 的連線中斷,推論已自動停止",
"workspace.offline.backToList": "返回裝置列表",
"workspace.tabs.camera": "Camera",
"workspace.tabs.image": "圖片Phase 1",
"workspace.tabs.video": "影片Phase 1",
"workspace.tabs.batch": "批次Phase 1",
// ── Settings ──
"settings.title": "設定",
"settings.subtitle": "管理偏好與雲端端點",
"settings.tabs.general": "一般",
"settings.tabs.advanced": "進階",
"settings.general.title": "偏好",
"settings.general.language": "語言",
"settings.general.theme": "主題",
"settings.general.themeHint": "跟隨系統偏好自動切換淺 / 深色",
"settings.advanced.title": "雲端端點",
"settings.advanced.apiEndpoint": "API Endpoint",
"settings.advanced.apiEndpointHint":
"正常使用者不需更改;開發時可切到 staging 或 localhost",
"settings.advanced.apiUrl": "目前 API URL",
"settings.advanced.wsUrl": "目前 WebSocket URL",
"settings.advanced.about": "關於",
"settings.advanced.version": "版本",
"settings.advanced.platform": "平台",
// ── PairingF7 新增)──
"pairing.title": "配對新裝置",
"pairing.subtitle": "讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作",
"pairing.token.title": "你的 Pairing Token",
"pairing.step1.description":
"複製下方 token在 15 分鐘內貼到 local agent 完成配對",
"pairing.copy": "複製",
"pairing.copied": "已複製",
"pairing.regenerate": "重新產生",
"pairing.timeRemaining": "剩餘 {time}",
"pairing.generatedAt": "產生時間:{time}",
"pairing.token.expired.label": "此 token 已過期,請重新產生",
"pairing.regenerateConfirm.title": "確定要重新產生?",
"pairing.regenerateConfirm.description":
"舊 token 將立即失效,新 token 有效期 15 分鐘",
"pairing.security.warning": "這組 token 15 分鐘內有效,請立刻完成配對",
"pairing.security.oneTime": "token 是一次性使用,完成配對後自動失效",
"pairing.toast.copied": "Token 已複製到剪貼簿15 分鐘內有效",
"pairing.toast.generateFailed": "無法產生 token請重試",
"pairing.toast.expiringSoon": "Token 即將過期,請立刻完成或重新產生",
"pairing.toast.pairedSuccess": "裝置 {deviceName} 已成功配對",
"pairing.toast.cliCopied": "指令已複製到剪貼簿",
"pairing.device.unknown": "未知裝置",
"pairing.cli.title": "CLI 指令範例",
"pairing.cli.description":
"在你的電腦啟動 local agent將 token 貼到指令的 --relay-token 參數",
"pairing.cli.copy": "複製指令",
"pairing.cli.hint":
"local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表",
"pairing.step3.waiting": "等待 local agent 連線…",
"pairing.step3.elapsed": "已等待 {time}(最長 3 分鐘)",
"pairing.step3.hints.running": "確認 local agent 已啟動",
"pairing.step3.hints.token": "確認 token 貼上時無缺字或多餘空白",
"pairing.step3.hints.network": "確認你的網路可連線到雲端",
"pairing.step3.success": "已成功連線!",
"pairing.step3.success.detected": "檢測到的裝置",
"pairing.step3.failure.timeout": "連線超時",
"pairing.step3.failure.reason":
"超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動",
"pairing.step3.failure.retry": "重新檢查",
// ── AccountPhase 0.6 OIDC──
// 設計重點:
// - 個人資料一律唯讀;修改入口統一導到 Innovedus 帳號中心
// - 「刪除帳號」按鈕保留為 disabled 雛形,文案說明改在 Member Center 處理
"account.title": "帳號設定",
"account.subtitle": "個人資料由 Innovedus 帳號中心管理",
"account.profile.title": "個人資料",
"account.profile.userId": "使用者 ID",
"account.profile.email": "電子郵件",
"account.profile.name": "顯示名稱",
"account.profile.namePlaceholder": "—",
"account.profile.managedBy":
"個人資料由 Innovedus 帳號中心管理。如需修改,請至 Innovedus 帳號中心。",
"account.profile.editAtMemberCenter": "前往 Innovedus 帳號中心",
"account.profile.editDisabledHint":
"尚未設定 Innovedus 帳號中心 URL請聯絡系統管理員。",
"account.session.title": "登入狀態",
"account.session.description":
"登出後會回到登入頁,下次需重新透過 Innovedus 帳號中心登入。",
"account.danger.title": "危險區",
"account.danger.description":
"帳號刪除請至 Innovedus 帳號中心處理visionA 不直接執行此操作。",
"account.danger.deleteAccount": "刪除帳號",
"account.danger.deleteAccount.tooltip":
"請至 Innovedus 帳號中心處理。",
// ── ClustersF7 新增 stub──
"clusters.title": "叢集",
"clusters.subtitle": "把多台 Kneron 裝置組成平行推論叢集",
"clusters.create": "建立叢集",
"clusters.empty.title": "叢集推論 — Phase 1 推出",
"clusters.empty.description":
"Phase 1 會把多裝置平行推論、降級機制等 POC 能力搬進雲端版",
"clusters.phase1Badge": "Phase 1 推出",
"clusters.phase1Toast": "叢集建立功能 Phase 1 才支援",
};

View File

@ -0,0 +1,69 @@
/**
* i18n + fallback
*
*
* 1. zh-Hant en key
* 2. dictionaries Locale Dictionary
* 3. isLocale /
* 4. key
*/
import { describe, expect, it } from "vitest";
import { dictionaries, DEFAULT_LOCALE, SUPPORTED_LOCALES, isLocale } from "./index";
describe("i18n — 字典完整性", () => {
it("SUPPORTED_LOCALES 應包含 zh-Hant 與 en", () => {
expect(SUPPORTED_LOCALES).toContain("zh-Hant");
expect(SUPPORTED_LOCALES).toContain("en");
});
it("DEFAULT_LOCALE 預設為 zh-Hant", () => {
expect(DEFAULT_LOCALE).toBe("zh-Hant");
});
it("zh-Hant 與 en 必須擁有相同的 key 集合(避免漏譯)", () => {
const zhKeys = Object.keys(dictionaries["zh-Hant"]).sort();
const enKeys = Object.keys(dictionaries.en).sort();
expect(zhKeys).toEqual(enKeys);
});
it("所有 key 的 value 必須為非空字串", () => {
for (const locale of SUPPORTED_LOCALES) {
const dict = dictionaries[locale];
for (const [key, value] of Object.entries(dict)) {
expect(typeof value, `[${locale}] ${key} 應為 string`).toBe("string");
expect(value.length, `[${locale}] ${key} 不應為空字串`).toBeGreaterThan(0);
}
}
});
});
describe("i18n — 字串查詢", () => {
it("zh-Hant 能取得 app.title", () => {
expect(dictionaries["zh-Hant"]["app.title"]).toBe("visionA Cloud");
});
it("en 能取得 nav.dashboard", () => {
expect(dictionaries.en["nav.dashboard"]).toBe("Dashboard");
});
it("找不到 key 時字典回傳 undefined由 t() 負責 fallback 到 key", () => {
expect(dictionaries["zh-Hant"]["non.existent.key"]).toBeUndefined();
});
});
describe("i18n — isLocale 型別守衛", () => {
it("合法 locale 回傳 true", () => {
expect(isLocale("zh-Hant")).toBe(true);
expect(isLocale("en")).toBe(true);
});
it("非法輸入回傳 false", () => {
expect(isLocale("zh-CN")).toBe(false);
expect(isLocale("")).toBe(false);
expect(isLocale(null)).toBe(false);
expect(isLocale(undefined)).toBe(false);
expect(isLocale(123)).toBe(false);
});
});

View File

@ -0,0 +1,22 @@
/**
* i18n visionA Cloud
*
*
* import { dictionaries, DEFAULT_LOCALE, type Locale } from "@/lib/i18n";
*/
import { en } from "./dictionaries/en";
import { zhHant } from "./dictionaries/zh-Hant";
import type { Dictionary, Locale } from "./types";
export { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, isLocale } from "./types";
export type { Dictionary, Locale } from "./types";
/**
*
* Client Context Provider locale `t()` key
*/
export const dictionaries: Record<Locale, Dictionary> = {
"zh-Hant": zhHant,
en,
};

View File

@ -0,0 +1,44 @@
"use client";
/**
* LocaleSync visionA Cloud
*
* mount localStorage `visionA.locale` LocaleProvider
* Provider useEffect
* 1. SSR hydration mismatchProvider render DEFAULT_LOCALE
* 2. <ThemeProvider> next-themes mount 使
* 3. <html lang="..."> SEO
*/
import { useEffect } from "react";
import { useLocale } from "./context";
import { LOCALE_STORAGE_KEY, isLocale } from "./types";
/**
* <LocaleProvider> root layout null DOM
*/
export function LocaleSync() {
const { locale, setLocale } = useLocale();
// 首次 mount從 localStorage 讀回使用者偏好
useEffect(() => {
if (typeof window === "undefined") return;
try {
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isLocale(stored) && stored !== locale) {
setLocale(stored);
}
} catch {
// localStorage 被禁用時靜默忽略
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- 僅需 mount 時執行一次
}, []);
// locale 變動時同步 <html lang>,對螢幕閱讀器與 SEO 友善
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = locale;
}, [locale]);
return null;
}

View File

@ -0,0 +1,36 @@
/**
* i18n visionA Cloud
*
* i18n next-intl Context + localStorage locale
* key`nav.dashboard` local-tool object
*
* 1. key Phase 0
* 2. TypeScript `keyof Dictionary` Paths<T>
* 3. F6 local-tool
*/
/** 支援的語系 — 繁體中文(預設)與英文。 */
export type Locale = "zh-Hant" | "en";
/** 全部支援語系清單(供 UI 產生語言切換選單)。 */
export const SUPPORTED_LOCALES: readonly Locale[] = ["zh-Hant", "en"] as const;
/** 預設語系 — 繁體中文。 */
export const DEFAULT_LOCALE: Locale = "zh-Hant";
/** localStorage 儲存 key統一前綴 visionA.* 避免與其他站點衝突)。 */
export const LOCALE_STORAGE_KEY = "visionA.locale";
/**
* 使 Record<string, string>
* key `dictionaries/zh-Hant.ts` `dictionaries/en.ts`
*/
export type Dictionary = Record<string, string>;
/**
* Locale
* localStorage / URL
*/
export function isLocale(value: unknown): value is Locale {
return typeof value === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}

View File

@ -0,0 +1,12 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* shadcn className helper
*
* - clsx class
* - tailwind-merge Tailwind `p-2 p-4` `p-4`
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1 @@
此目錄存放 Zustand storesauth-store、session-store 等),由 F5 任務填入。

View File

@ -0,0 +1,78 @@
/**
* Activity Store visionA CloudDashboard
*
*
* - `.autoflow/03-design/components.md` §4 Dashboard `ActivityTimeline`
* - `.autoflow/03-design/flows/flow-offline-handling.md` §9 Activity Timeline
*
*
* -
* - 100 drop
*
* F6
* - F7/F8 WS `/ws/devices/events` + login / upload seed
* - store Dashboard render empty state F7+
*/
"use client";
import { create } from "zustand";
/** 活動類型(對齊 flow-offline-handling.md §9 雲端版 + local-tool 既有類型) */
export type ActivityType =
| "device_paired"
| "device_unpaired"
| "device_online"
| "device_offline"
| "tunnel_reconnected"
| "flash_start"
| "flash_complete"
| "flash_error"
| "model_upload"
| "model_delete"
| "cluster_degraded";
export interface ActivityEntry {
id: string;
type: ActivityType;
message: string;
/** Unix ms用 `Date.now()`;方便前端相對時間格式化) */
timestamp: number;
/** 可選關聯 */
deviceId?: string;
modelId?: string;
}
const ACTIVITY_LIMIT = 100;
interface ActivityState {
activities: ActivityEntry[];
addActivity: (entry: Omit<ActivityEntry, "id" | "timestamp"> & {
id?: string;
timestamp?: number;
}) => void;
clear: () => void;
}
export const useActivityStore = create<ActivityState>()((set) => ({
activities: [],
addActivity: (entry) =>
set((state) => {
const id = entry.id ?? `act_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const timestamp = entry.timestamp ?? Date.now();
const next: ActivityEntry = {
id,
type: entry.type,
message: entry.message,
timestamp,
deviceId: entry.deviceId,
modelId: entry.modelId,
};
// 新事件放最前面,超過上限從尾部 drop
const combined = [next, ...state.activities].slice(0, ACTIVITY_LIMIT);
return { activities: combined };
}),
clear: () => set({ activities: [] }),
}));

View File

@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAuthStore } from "./auth-store";
/**
* auth-store OF2 / Phase 0.6 OIDC BFF
*
*
* - oidc-tdd.md §3 BFF flow / §10.1 frontend
* - security.md §14.1localStorage token
*
*
* - state / isAuthenticated derive
* - hydrate 200 user
* - hydrate 401 user = nullvisitor error
* - hydrate 5xx / error user = null
* - hydrate payload
* - fetchMe hydrate
* - logout 200 user
* - logout backend userbest-effort
* - localStorageOF6
*/
function resetStore() {
useAuthStore.setState({
user: null,
isLoading: false,
error: null,
});
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("auth-store", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
window.localStorage.clear();
resetStore();
});
afterEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
});
/* ------------------------------------------------------------------------ */
/* 初始 state */
/* ------------------------------------------------------------------------ */
it("初始 state 為未登入isAuthenticated 由 user 衍生", () => {
const s = useAuthStore.getState();
expect(s.user).toBeNull();
expect(s.error).toBeNull();
expect(s.isAuthenticated()).toBe(false);
});
it("_setUser 後 isAuthenticated 為 true", () => {
useAuthStore
.getState()
._setUser({ id: "u1", email: "a@b.c", name: "Alice" });
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
expect(useAuthStore.getState().user?.id).toBe("u1");
});
/* ------------------------------------------------------------------------ */
/* hydrate / fetchMe */
/* ------------------------------------------------------------------------ */
it("hydrate200 + envelope 解開後設 usermapping snake_case → camelCase", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: {
user_id: "demo-user",
email: "demo@visionA.local",
name: "Demo User",
},
}),
);
await useAuthStore.getState().hydrate();
const s = useAuthStore.getState();
expect(s.user).toEqual({
id: "demo-user",
email: "demo@visionA.local",
name: "Demo User",
});
expect(s.isAuthenticated()).toBe(true);
expect(s.error).toBeNull();
expect(s.isLoading).toBe(false);
});
it("hydratename 為 undefined 時保留為 undefined讓 UI 端用 ?? 做語意明確的 fallback", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: { user_id: "u", email: "u@x.com" },
}),
);
await useAuthStore.getState().hydrate();
expect(useAuthStore.getState().user?.name).toBeUndefined();
expect(useAuthStore.getState().user?.email).toBe("u@x.com");
});
it("hydrateMC 沒給 email / nameresponse 只有 user_id→ user 仍建立、UI 須自行 fallback", async () => {
// 真實情境Phase 0.7 stage backend log 顯示 /api/auth/me 200 但 response 只有
// user_idMC id_token 沒帶 email / name claim先前實作會讓 UI 因 charAt
// undefined 而 client-side exception。
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: { user_id: "b5332e51-c394-45c7-a28a-83e6da127ba9" },
}),
);
await useAuthStore.getState().hydrate();
const s = useAuthStore.getState();
expect(s.user).toEqual({
id: "b5332e51-c394-45c7-a28a-83e6da127ba9",
email: undefined,
name: undefined,
});
expect(s.isAuthenticated()).toBe(true);
expect(s.error).toBeNull();
});
it("hydrate請求帶 credentials: 'include'(瀏覽器自動帶 visiona_session cookie", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: { user_id: "u", email: "u@x.com", name: "" },
}),
);
await useAuthStore.getState().hydrate();
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.credentials).toBe("include");
});
it("hydrate401 → user = null、不視為 error", async () => {
fetchMock.mockResolvedValue(
jsonResponse(401, {
success: false,
error: { code: "UNAUTHORIZED", message: "not authenticated" },
}),
);
await useAuthStore.getState().hydrate();
const s = useAuthStore.getState();
expect(s.user).toBeNull();
expect(s.error).toBeNull(); // visitor 是正常情境
expect(s.isLoading).toBe(false);
});
it("hydrate5xx → user = null、error 有訊息", async () => {
fetchMock.mockResolvedValue(
jsonResponse(500, {
success: false,
error: { code: "INTERNAL_ERROR", message: "boom" },
}),
);
await useAuthStore.getState().hydrate();
const s = useAuthStore.getState();
expect(s.user).toBeNull();
expect(s.error).toBe("boom");
});
it("hydratefetch reject網路錯誤 / CORS 拒絕)→ user = null、error 有訊息", async () => {
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
await useAuthStore.getState().hydrate();
const s = useAuthStore.getState();
expect(s.user).toBeNull();
expect(s.error).toBeTruthy();
});
it("hydratepayload 不含 user_id 時當作未登入", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: { foo: "bar" },
}),
);
await useAuthStore.getState().hydrate();
expect(useAuthStore.getState().user).toBeNull();
});
it("fetchMe行為等同 hydrate", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: { user_id: "u", email: "u@x.com", name: "U" },
}),
);
await useAuthStore.getState().fetchMe();
expect(useAuthStore.getState().user?.id).toBe("u");
});
/* ------------------------------------------------------------------------ */
/* logout */
/* ------------------------------------------------------------------------ */
it("logout200 後清 user", async () => {
useAuthStore
.getState()
._setUser({ id: "u", email: "e@x", name: "U" });
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
fetchMock.mockResolvedValue(
jsonResponse(200, { success: true, data: { success: true } }),
);
await useAuthStore.getState().logout();
const s = useAuthStore.getState();
expect(s.user).toBeNull();
expect(s.isAuthenticated()).toBe(false);
// 確認 POST 並帶 cookie
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toMatch(/\/api\/auth\/logout$/);
expect(init.method).toBe("POST");
expect(init.credentials).toBe("include");
});
it("logoutbackend 失敗5xx / 網路)也仍清前端 user", async () => {
useAuthStore
.getState()
._setUser({ id: "u", email: "e@x", name: "U" });
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
await useAuthStore.getState().logout();
expect(useAuthStore.getState().user).toBeNull();
});
/* ------------------------------------------------------------------------ */
/* 安全債清理security.md §14.1 */
/* ------------------------------------------------------------------------ */
it("OF2所有 actions 都不寫入 localStorage已移除 visionA.auth.* keys", async () => {
fetchMock.mockResolvedValue(
jsonResponse(200, {
success: true,
data: { user_id: "u", email: "u@x.com", name: "U" },
}),
);
await useAuthStore.getState().hydrate();
await useAuthStore.getState().fetchMe();
fetchMock.mockResolvedValue(
jsonResponse(200, { success: true, data: { success: true } }),
);
await useAuthStore.getState().logout();
expect(window.localStorage.getItem("visionA.auth.token")).toBeNull();
expect(window.localStorage.getItem("visionA.auth.user")).toBeNull();
// 整個 localStorage 應為空
expect(window.localStorage.length).toBe(0);
});
});

View File

@ -0,0 +1,156 @@
/**
* Auth Store visionA CloudOF2 / Phase 0.6 OIDC BFF
*
*
* - `.autoflow/04-architecture/oidc-tdd.md` §10.1 frontend §3 BFF flow
* - `.autoflow/04-architecture/security.md` §14.1localStorage token
* - `.autoflow/03-design/flows/flow-auth.md`
*
*
* - 使 backend `GET /api/auth/me`
* - hydrateapp boot/ fetchMe refresh/ logout actions
*
* BFF
* - **frontend OIDC token**access_token / id_token backend cookie session
* - lib/api.ts `credentials: 'include'`browser visiona_session cookie
* - login flow store /login redirect backend `/api/auth/login`OF1
* - Member Center visionA frontend
* - **使 localStorage** OF6 localStorage
* session backend cookie hydrate me
*/
"use client";
import { create } from "zustand";
import { ApiError, api } from "@/lib/api";
import type { User } from "@/types/user";
/* -------------------------------------------------------------------------- */
/* Backend payloadsnake_case對齊 oidcMeHandler */
/* -------------------------------------------------------------------------- */
/**
* `GET /api/auth/me` envelope `data` payload
* visionA-backend `internal/api/oidc_auth.go` `MeResponseOIDC`
* { user_id, email?, name? }
*
* **email / name optional**backend Go struct `omitempty` JSON tag
* MC id_token claim scope / MC response
* keyPhase 0.7 stage response `user_id`
*/
interface MeResponse {
user_id: string;
email?: string;
name?: string;
}
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface AuthState {
/** 當前使用者;未登入時為 null */
user: User | null;
/** hydrate / fetchMe / logout 進行中時為 true供 UI 顯示 loading */
isLoading: boolean;
/** 最近一次操作的非預期錯誤401visitor不算 error */
error: string | null;
/** 是否已登入衍生user !== null */
isAuthenticated: () => boolean;
/**
* App boot
* - 200 user error
* - 401 user = nullvisitor error
* - error backend app
*/
hydrate: () => Promise<void>;
/** 手動 refresh 當前 user行為與 hydrate 一致;分離名稱讓呼叫意圖更清楚) */
fetchMe: () => Promise<void>;
/**
* backend `POST /api/auth/logout` server session + cookie
* user state
*/
logout: () => Promise<void>;
/** 測試 / 開發用:直接塞 user不走 API。 */
_setUser: (user: User | null) => void;
}
/* -------------------------------------------------------------------------- */
/* 將後端 MeResponse 轉為前端 User */
/* -------------------------------------------------------------------------- */
function mapMeToUser(me: MeResponse): User {
// email / name 缺失時保留為 undefined不轉空字串讓 TS type system 強迫
// 呼叫端做 fallback避免「以為是 string 結果是 undefined」的 runtime crash
// 歷史備註:先前實作回傳 `name: me.name ?? ""`,會讓 `name && name.length > 0`
// 直接 falsy → fallback 到 email但若 email 也 undefinedUI 會吃到
// `undefined.charAt(0)` 而 React tree crashclient-side exception
return {
id: me.user_id,
email: me.email,
name: me.name,
};
}
/* -------------------------------------------------------------------------- */
/* Store */
/* -------------------------------------------------------------------------- */
export const useAuthStore = create<AuthState>()((set, get) => ({
user: null,
isLoading: false,
error: null,
isAuthenticated: () => get().user !== null,
hydrate: async () => {
set({ isLoading: true });
try {
const me = await api.get<MeResponse>("/api/auth/me");
// 後端 envelope 拆完後是 { user_id, email, name? }
if (me && typeof me === "object" && typeof me.user_id === "string") {
set({ user: mapMeToUser(me), isLoading: false, error: null });
} else {
// 雛形保險payload 不符預期時當作未登入
set({ user: null, isLoading: false, error: null });
}
} catch (err) {
// 401 / UNAUTHORIZED → visitor 模式(正常情境,不顯示 error
if (err instanceof ApiError && (err.status === 401 || err.code === "UNAUTHORIZED")) {
set({ user: null, isLoading: false, error: null });
return;
}
// 其他錯誤(網路 / 5xx / parse— 記下訊息但不擋 UI
const message = err instanceof Error ? err.message : "Failed to fetch session";
set({ user: null, isLoading: false, error: message });
}
},
fetchMe: async () => {
// 行為等同 hydrate分名稱讓呼叫端意圖更清楚callsite 例:手動「重新取得帳號資訊」)
await get().hydrate();
},
logout: async () => {
set({ isLoading: true });
try {
// backend 會清 server session + 回 Set-Cookie 把 visiona_session 過期
await api.post("/api/auth/logout");
} catch {
// 即使 backend 失敗(網路 / 5xx— 仍然清前端 state
// 否則使用者卡在「無法登出」。下次發 API 若 cookie 還在就照樣帶,
// backend session 已 best-effort 嘗試清除。
}
set({ user: null, isLoading: false, error: null });
},
_setUser: (user) => {
set({ user, error: null });
},
}));

View File

@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useDevicePreferencesStore } from "./device-preferences-store";
function reset() {
useDevicePreferencesStore.setState({
preferences: {},
connectionLog: [],
});
}
describe("device-preferences-store", () => {
beforeEach(() => {
window.localStorage.clear();
reset();
});
afterEach(() => {
window.localStorage.clear();
});
it("setAlias 寫入該裝置偏好", () => {
useDevicePreferencesStore.getState().setAlias("dev-1", "我的 KL520");
const p = useDevicePreferencesStore.getState().getPreferences("dev-1");
expect(p.alias).toBe("我的 KL520");
expect(p.notes).toBe(""); // 預設
});
it("setNotes 不會覆蓋 alias", () => {
const s = useDevicePreferencesStore.getState();
s.setAlias("dev-1", "alias");
s.setNotes("dev-1", "一些備註");
const p = useDevicePreferencesStore.getState().getPreferences("dev-1");
expect(p.alias).toBe("alias");
expect(p.notes).toBe("一些備註");
});
it("getPreferences 對未設定的裝置回傳預設值", () => {
const p = useDevicePreferencesStore.getState().getPreferences("unknown");
expect(p).toEqual({ alias: "", notes: "" });
});
it("addConnectionLog 只保留最新 200 筆", () => {
const s = useDevicePreferencesStore.getState();
for (let i = 0; i < 250; i++) {
s.addConnectionLog({
deviceId: "dev-1",
event: i % 2 === 0 ? "connected" : "disconnected",
timestamp: i,
});
}
const log = useDevicePreferencesStore.getState().connectionLog;
expect(log.length).toBe(200);
// 最新一筆的 timestamp 應該是 249
expect(log[log.length - 1].timestamp).toBe(249);
});
});

Some files were not shown because too many files have changed in this diff Show More