jim800121chen 99dea42239 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>
2026-05-01 11:21:36 +08:00

158 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}