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

103 lines
3.0 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";
/**
* 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>
);
}