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>
103 lines
3.0 KiB
TypeScript
103 lines
3.0 KiB
TypeScript
"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>
|
||
);
|
||
}
|