diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index f5418936..356c151d 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -43,7 +43,7 @@ test("真实 session 打开控制台并呈现隔离用户与真实空态", async await expect(page.getByTestId("overview-tiles")).toBeVisible(); await expect(page.getByTestId("empty-agents")).toBeVisible(); await page.goto("/devices"); - await expect(page.getByText(/还没有任何设备|No devices yet/i)).toBeVisible(); + await expect(page.getByTestId("add-device-guide")).toBeVisible(); await page.goto("/chat"); await expect(page.getByTestId("chat-empty-state")).toBeVisible(); diff --git a/frontend/src/__tests__/device-guide.test.tsx b/frontend/src/__tests__/device-guide.test.tsx new file mode 100644 index 00000000..3ad116b8 --- /dev/null +++ b/frontend/src/__tests__/device-guide.test.tsx @@ -0,0 +1,345 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://console.example.test/devices" } + */ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { + BrowserRouter, + MemoryRouter, + Route, + Routes, + useLocation, +} from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AddDeviceGuide } from "@/components/AddDeviceGuide"; +import i18n from "@/i18n"; +import { ApiError, api } from "@/lib/api"; +import { DEVICE_FLOW_CODES } from "@/lib/errorCodes"; +import { ThemeProvider } from "@/lib/theme"; +import Device from "@/pages/Device"; + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: vi.fn() }; +}); + +const mockedApi = vi.mocked(api); + +/** 提交后跳到哪里:用真的 router 位置作答,不去 mock useNavigate。 */ +function LocationProbe() { + const loc = useLocation(); + return {`${loc.pathname}${loc.search}`}; +} + +/** + * 页内三步引导的内容契约(规格「web 控制台:设备页 · 三步 / 第 1 步 · 装 / + * 第 2 步 · 登录 / 第 3 步 · 输码」)。 + * + * 这个文件的 jsdom URL 被换成了一个虚构域名:登录命令里的服务器地址必须是 + * **当前控制台自己的地址**,写死任何域名(包括 mockup 里的 hub.agentre.ai) + * 都会在这里红掉。 + */ +function renderGuide() { + return render( + + + + , + { wrapper: ThemeProvider }, + ); +} + +/** 六格码格(引导第 3 步与既有输码屏共用同一个组件,所以定位方式也一样)。 */ +function boxes(): HTMLInputElement[] { + return within( + screen.getByRole("group", { name: "Device code" }), + ).getAllByRole("textbox") as HTMLInputElement[]; +} + +function typeCode(text: string) { + text.split("").forEach((ch, i) => { + const box = boxes()[i]; + box.focus(); + fireEvent.change(box, { target: { value: ch } }); + }); +} + +function values() { + return boxes().map((b) => b.value); +} + +function openCodeStep() { + fireEvent.click(screen.getByTestId("add-device-step-3")); +} + +function submitCode() { + fireEvent.click(screen.getByRole("button", { name: "Continue" })); +} + +const INSTALL_UNIX = + "curl -fsSL https://github.com/agentre-ai/agentre/releases/latest/download/install.sh | sh"; +const INSTALL_WIN = + "irm https://github.com/agentre-ai/agentre/releases/latest/download/install.ps1 | iex"; + +beforeEach(async () => { + await i18n.changeLanguage("en"); + // 下面有一条用真 BrowserRouter 走完整跳转的用例,它会改掉 jsdom 的地址; + // 每个用例都从设备页重新开始,免得互相污染。 + window.history.replaceState({}, "", "/devices"); + mockedApi.mockReset(); + mockedApi.mockRejectedValue(new Error("unexpected call")); +}); + +describe("add-device guide · steps and commands", () => { + it("只提供两种设备类型:计算节点与桌面端(浏览器/移动端不是可加的类型)", () => { + renderGuide(); + + expect( + screen.getByRole("button", { name: "Compute node (agentred)" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Desktop (Agentre App)" }), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /browser/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /mobile/i })).toBeNull(); + }); + + it("第 1 步 · 计算节点:给出所选系统的安装命令与后台服务命令", () => { + renderGuide(); + + expect(screen.getByTestId("add-device-command-install").textContent).toBe( + INSTALL_UNIX, + ); + expect(screen.getByTestId("add-device-command-service").textContent).toBe( + "agentred service install --start", + ); + + fireEvent.click(screen.getByRole("button", { name: "Windows" })); + expect(screen.getByTestId("add-device-command-install").textContent).toBe( + INSTALL_WIN, + ); + + fireEvent.click(screen.getByRole("button", { name: "macOS" })); + expect(screen.getByTestId("add-device-command-install").textContent).toBe( + INSTALL_UNIX, + ); + }); + + it("第 1 步 · 桌面端:换成下载入口,不再有系统切换与 agentred 安装命令", () => { + renderGuide(); + + fireEvent.click( + screen.getByRole("button", { name: "Desktop (Agentre App)" }), + ); + + expect(screen.queryByTestId("add-device-command-install")).toBeNull(); + expect(screen.queryByRole("button", { name: "Windows" })).toBeNull(); + const download = screen.getByTestId("add-device-download"); + expect(download.getAttribute("href")).toBe( + "https://github.com/agentre-ai/agentre/releases/latest", + ); + }); + + it("第 2 步 · 计算节点:登录命令带的是当前控制台地址,不是写死的域名", () => { + renderGuide(); + + fireEvent.click(screen.getByTestId("add-device-step-2")); + + expect(screen.getByTestId("add-device-command-login").textContent).toBe( + "agentred login --server https://console.example.test", + ); + + const body = screen.getByTestId("add-device-step-body").textContent ?? ""; + // 命令会打印 6 位码、会尝试开浏览器、没浏览器就把码带回来 + expect(body).toMatch(/User code/); + expect(body).toMatch(/browser/i); + // 有效期可配置:这一屏不许写死任何时长 + expect(body).not.toMatch(/\d+\s*(minutes?|分钟)/); + }); + + it("第 2 步 · 桌面端:同一个控制台地址 + 应用内登录路径", () => { + renderGuide(); + + fireEvent.click( + screen.getByRole("button", { name: "Desktop (Agentre App)" }), + ); + fireEvent.click(screen.getByTestId("add-device-step-2")); + + expect(screen.queryByTestId("add-device-command-login")).toBeNull(); + expect(screen.getByTestId("add-device-server-address").textContent).toBe( + "https://console.example.test", + ); + const body = screen.getByTestId("add-device-step-body").textContent ?? ""; + expect(body).toMatch(/Settings/); + expect(body).not.toMatch(/\d+\s*(minutes?|分钟)/); + }); + + it("步骤条三格都可点:跳到第 3 步只换当前步骤,不伪造完成标记", () => { + renderGuide(); + + const step1 = screen.getByTestId("add-device-step-1"); + const step3 = screen.getByTestId("add-device-step-3"); + expect(step1.getAttribute("aria-current")).toBe("step"); + + fireEvent.click(step3); + + expect(step3.getAttribute("aria-current")).toBe("step"); + expect(step1.getAttribute("aria-current")).toBeNull(); + // 跳步过去的:第 1/2 步没有点过「下一步」,就不该显示完成 + expect(within(step1).queryByText("Installed")).toBeNull(); + expect( + within(screen.getByTestId("add-device-step-2")).queryByText("Signed in"), + ).toBeNull(); + }); + + it("点过某一步的「下一步」才出现完成标记,并前进到下一步", () => { + renderGuide(); + + fireEvent.click(screen.getByRole("button", { name: "Installed — next" })); + + const step1 = screen.getByTestId("add-device-step-1"); + const step2 = screen.getByTestId("add-device-step-2"); + expect(within(step1).getByText("Installed")).toBeTruthy(); + expect(step2.getAttribute("aria-current")).toBe("step"); + expect(within(step2).queryByText("Signed in")).toBeNull(); + }); + + it("第 3 步用的就是既有的六格设备码输入,校验规则一模一样", () => { + renderGuide(); + openCodeStep(); + + const bs = boxes(); + expect(bs).toHaveLength(6); + expect(bs[0].getAttribute("aria-label")).toBe("Character 1 of 6"); + // 字母表外的字形(0/O/1/I)进不来——与既有输码屏同一套规则, + // 说明这里用的是同一个组件而不是第二份码格。 + fireEvent.change(bs[0], { target: { value: "O" } }); + expect(values()[0]).toBe(""); + }); + + it("填满六位提交:跳到既有授权确认屏,带上归一化后的设备码", () => { + renderGuide(); + openCodeStep(); + + typeCode("a4f7q2"); + submitCode(); + + expect(screen.getByTestId("location").textContent).toBe( + "/device?user_code=A4F-7Q2", + ); + }); + + it("不足六位:就地报错、不跳转,已填的字符一个都不丢", () => { + renderGuide(); + openCodeStep(); + + typeCode("a4f"); + submitCode(); + + expect(screen.getByTestId("location").textContent).toBe("/devices"); + expect(screen.getByText("Enter all six characters.")).toBeTruthy(); + expect(values()).toEqual(["A", "4", "F", "", "", ""]); + expect(boxes()[0].getAttribute("aria-invalid")).toBe("true"); + }); + + it("再动一下输入就撤掉红态", () => { + renderGuide(); + openCodeStep(); + + typeCode("a4f"); + submitCode(); + typeCode("a4f7"); + + expect(screen.queryByText("Enter all six characters.")).toBeNull(); + expect(boxes()[0].getAttribute("aria-invalid")).toBeNull(); + }); + + it("第 3 步不复制授权确认屏的任何一项(风险说明/代码核对/允许拒绝/倒计时)", () => { + renderGuide(); + openCodeStep(); + + expect( + screen.queryByRole("button", { name: /allow access|deny/i }), + ).toBeNull(); + const body = screen.getByTestId("add-device-step-body").textContent ?? ""; + expect(body).not.toMatch(/full access/i); // 风险说明 + expect(body).not.toMatch(/matches exactly/i); // 代码核对 + expect(body).not.toMatch(/expires in/i); // 过期倒计时 + }); + + it("复制按钮把命令原样交给剪贴板", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + renderGuide(); + fireEvent.click(screen.getByTestId("add-device-copy-install")); + + expect(writeText).toHaveBeenCalledWith(INSTALL_UNIX); + expect(await screen.findByText("Copied")).toBeTruthy(); + }); + + it("复制完再换系统:命令换了「已复制」就得撤掉,剪贴板里躺着的还是上一条", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + renderGuide(); + fireEvent.click(screen.getByTestId("add-device-copy-install")); + expect(await screen.findByText("Copied")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Windows" })); + + expect(screen.getByTestId("add-device-command-install").textContent).toBe( + INSTALL_WIN, + ); + // 这条 PowerShell 命令一次都没进过剪贴板 + expect(writeText).toHaveBeenCalledTimes(1); + expect(screen.queryByText("Copied")).toBeNull(); + }); +}); + +/** + * 第 3 步只负责把设备码交出去;「这个代码存不存在」由既有的授权确认屏回答 + * (规格「不新增第二份批准界面」)。这条用例走真 router 的整段交接,证明 + * 交出去的形态那一屏收得下:代码不存在时它沿用既有文案就地标红, + * 六格里的字符一个不丢。 + */ +describe("add-device guide · 第 3 步交接给既有授权确认屏", () => { + it("代码不存在或已被使用:就地标红且保留已填字符", async () => { + mockedApi.mockRejectedValue( + new ApiError( + DEVICE_FLOW_CODES.DeviceFlowUserCodeInvalid, + "user_code invalid", + 400, + ), + ); + + render( + + + } /> + } /> + + , + { wrapper: ThemeProvider }, + ); + + openCodeStep(); + typeCode("a4f7q2"); + submitCode(); + + expect(await screen.findByText(/already been used/i)).toBeTruthy(); + expect(values()).toEqual(["A", "4", "F", "7", "Q", "2"]); + const lookups = mockedApi.mock.calls.filter(([path]) => + path.startsWith("/v1/oauth/device/pending"), + ); + expect(lookups).toHaveLength(1); + expect(lookups[0][0]).toContain("user_code=A4F-7Q2"); + }); +}); diff --git a/frontend/src/__tests__/device-management.test.tsx b/frontend/src/__tests__/device-management.test.tsx index dd30edbc..06d674d8 100644 --- a/frontend/src/__tests__/device-management.test.tsx +++ b/frontend/src/__tests__/device-management.test.tsx @@ -150,7 +150,7 @@ describe("device management page", () => { // 列表加载失败必须说出来。只认 ApiError 会让「代理返回非 JSON 的 502」「浏览器离线」 // 这类 SyntaxError / TypeError 被静默吞掉,而 finally 照样把 loading 置 false —— - // 用户看到的是「还没有任何设备」,而他名下的设备一台没少。 + // 空态如今就是自动展开的添加引导,于是名下设备一台没少的用户会被请去加第一台。 it("reports a load failure instead of rendering the empty state", async () => { mockedApi.mockImplementation(async () => { throw new SyntaxError("Unexpected token '<' ... is not valid JSON"); @@ -161,7 +161,7 @@ describe("device management page", () => { expect( await screen.findByText("Could not load your devices. Please try again."), ).toBeTruthy(); - expect(screen.queryByText("No devices yet.")).toBeNull(); + expect(screen.queryByTestId("add-device-guide")).toBeNull(); }); it("keeps the device and shows an error when revoke fails", async () => { diff --git a/frontend/src/__tests__/devices.test.tsx b/frontend/src/__tests__/devices.test.tsx index 713101bf..52354ce9 100644 --- a/frontend/src/__tests__/devices.test.tsx +++ b/frontend/src/__tests__/devices.test.tsx @@ -357,3 +357,80 @@ describe("device page design alignment", () => { expect(deviceKindIcon("web")).toBe(Monitor); }); }); + +// 规格「web 控制台:设备页 · 入口与展开条件」:整页只有一个「添加设备」入口, +// 点它就地在列表上方展开三步引导;空态默认展开并取代那句孤立的空句; +// 列表加载失败时只留既有错误 —— 不展开引导,也不改口说没有设备。 +describe("add-device entry and guide expansion", () => { + it("有设备时:列表上方只有一个『添加设备』入口,引导不渲染", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") return listResponse; + throw new Error("unexpected call: " + path); + }); + + renderDevices(); + await screen.findByText("nuc-01"); + + expect(screen.getAllByRole("button", { name: "Add device" })).toHaveLength( + 1, + ); + expect(screen.queryByTestId("add-device-guide")).toBeNull(); + }); + + it("点入口:引导展开在列表上方、入口消失;收起后回到原样", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") return listResponse; + throw new Error("unexpected call: " + path); + }); + + renderDevices(); + await screen.findByText("nuc-01"); + + fireEvent.click(screen.getByRole("button", { name: "Add device" })); + + const guide = screen.getByTestId("add-device-guide"); + const firstRow = screen.getByTestId("device-row-1"); + // 「在列表上方」:引导在文档顺序里排在第一台设备之前 + expect( + guide.compareDocumentPosition(firstRow) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + // 展开时入口不再渲染(全页仍然只有一个添加入口) + expect(screen.queryByRole("button", { name: "Add device" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Collapse guide" })); + expect(screen.queryByTestId("add-device-guide")).toBeNull(); + expect(screen.getByRole("button", { name: "Add device" })).toBeTruthy(); + }); + + it("空态:引导默认展开取代孤立空句,且不提供收起", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") return { devices: [] }; + throw new Error("unexpected call: " + path); + }); + + renderDevices(); + + expect(await screen.findByTestId("add-device-guide")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Collapse guide" })).toBeNull(); + // 引导已经展开,重复的入口不再渲染 + expect(screen.queryByRole("button", { name: "Add device" })).toBeNull(); + }); + + it("加载失败:只显示既有错误,不展开引导、也不说没有设备", async () => { + mockedApi.mockImplementation(async () => { + throw new SyntaxError("Unexpected token '<' ... is not valid JSON"); + }); + + renderDevices(); + + expect( + await screen.findByText("Could not load your devices. Please try again."), + ).toBeTruthy(); + expect(screen.queryByTestId("add-device-guide")).toBeNull(); + expect(screen.queryByRole("button", { name: "Add device" })).toBeNull(); + // 「没有设备」如今有两个说法:展开的引导,和顶栏那个数字。取不到列表时 + // 两个都不许出现——写着 0 的计数和那句被删掉的空句是同一句谎话。 + expect(screen.queryByTestId("devices-count")).toBeNull(); + }); +}); diff --git a/frontend/src/components/AddDeviceGuide.tsx b/frontend/src/components/AddDeviceGuide.tsx new file mode 100644 index 00000000..8e72539a --- /dev/null +++ b/frontend/src/components/AddDeviceGuide.tsx @@ -0,0 +1,533 @@ +import { useEffect, useId, useState, type FormEvent } from "react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { + ArrowRight, + Check, + CircleAlert, + Copy, + Download, + X, +} from "lucide-react"; + +import CodeInput from "@/components/CodeInput"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { normalize, toChars } from "@/lib/userCode"; +import { cn } from "@/lib/utils"; + +/** 能被「加进来」的设备类型。浏览器不用加(用户正开着的这个已经在列表里),移动端没有可装的客户端。 */ +type AddKind = "agentred" | "desktop"; +type TargetOS = "linux" | "macos" | "windows"; + +const RELEASES_URL = "https://github.com/agentre-ai/agentre/releases/latest"; + +/** 与桌面端引导给的是同一条命令;改这里之前先确认那边也改了。 */ +const INSTALL_UNIX = `curl -fsSL ${RELEASES_URL}/download/install.sh | sh`; +const INSTALL_WINDOWS = `irm ${RELEASES_URL}/download/install.ps1 | iex`; +const SERVICE_INSTALL = "agentred service install --start"; + +const STEP_KEYS = ["install", "login", "code"] as const; + +/** + * 登录命令里的服务器地址 = **这个控制台自己的地址**。 + * + * 写死域名(哪怕是 mockup 里的占位域名)会让自建部署的用户照抄一条连不上的命令, + * 而错误要等到那台机器上才暴露。控制台知道自己的地址,用户不该手抄。 + */ +function consoleOrigin(): string { + return window.location.origin; +} + +/** 一条可复制的命令。剪贴板不可用(非安全上下文)时不渲染复制按钮——没有能力就不给控件。 */ +function CommandCard({ + label, + command, + testId, + copyTestId, +}: { + label: string; + command: string; + testId: string; + copyTestId: string; +}) { + const { t } = useTranslation(); + // 记的是「复制走的是哪一条命令」而不是「复制过没有」:切换系统会把同一张卡 + // 换成另一条命令,只记一个布尔值的话,按钮就会对着一条从没进过剪贴板的命令 + // 说「已复制」。 + const [copiedCommand, setCopiedCommand] = useState(null); + const copied = copiedCommand === command; + + // 计时从点击那一刻起算,与卡片此刻显示哪条命令无关。 + useEffect(() => { + if (copiedCommand === null) return; + const timer = window.setTimeout(() => setCopiedCommand(null), 2000); + return () => window.clearTimeout(timer); + }, [copiedCommand]); + + const clipboard = + typeof navigator === "undefined" ? undefined : navigator.clipboard; + + return ( +
+
+ + {label} + + {clipboard && ( + + )} +
+
+        {command}
+      
+
+ ); +} + +/** 选项按钮(设备类型 / 系统):当前选中态由 aria-pressed 表达,不靠颜色。 */ +function ChoiceButton({ + selected, + onSelect, + children, +}: { + selected: boolean; + onSelect: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function FieldLabel({ label, hint }: { label: string; hint?: string }) { + return ( +
+ {label} + {hint && {hint}} +
+ ); +} + +function StepHead({ + step, + title, + description, +}: { + step: number; + title: string; + description: string; +}) { + const { t } = useTranslation(); + return ( +
+ + {t("device.add.stepOf", { n: step })} + +

{title}

+

+ {description} +

+
+ ); +} + +/** 步骤正文下面的说明清单(会打印什么、会不会自己开浏览器、过期怎么办)。 */ +function TipList({ tips }: { tips: string[] }) { + return ( +
    + {tips.map((tip, i) => ( +
  1. + + {i + 1} + + {tip} +
  2. + ))} +
+ ); +} + +/** + * 「怎么加一台设备」的页内引导:装上 agentred → 让它登录账号 → 输入设备码。 + * + * 只在设备页由唯一的「添加设备」入口召唤(空态默认展开),不是常驻区块。 + * 传了 onClose 才渲染收起控件——空态没有别的东西可看,收起等于把页面清空。 + * + * 完成标记只跟「用户点过那一步的下一步」走:从步骤条直接跳到第 3 步不会给 + * 前两步补上勾,否则那个勾就是我们替用户编的。 + */ +export function AddDeviceGuide({ onClose }: { onClose?: () => void }) { + const { t } = useTranslation(); + const nav = useNavigate(); + const [step, setStep] = useState(1); + const [kind, setKind] = useState("agentred"); + const [os, setOS] = useState("linux"); + const [done, setDone] = useState>(new Set()); + const [chars, setChars] = useState(() => toChars("")); + const [incomplete, setIncomplete] = useState(false); + const codeErrorId = useId(); + + function finishStep(n: number) { + setDone((prev) => new Set(prev).add(n)); + setStep(n + 1); + } + + /** + * 第 3 步只做本地归一化,然后把设备码交给既有的授权确认屏。 + * + * 「这个代码存不存在 / 是不是已经用过」不在这里问:那一屏拿到 user_code + * 就会自己查 pending,查不到时用同一套 device.entry.errors 就地标红且 + * 保留已填字符。在这里再查一遍等于把那一屏的错误呈现复制第二份, + * 而两份安全界面必然漂移(规格「不新增第二份批准界面」)。 + */ + function submitCode(e: FormEvent) { + e.preventDefault(); + const norm = normalize(chars.join("")); + // 不足六位(码格本身已挡下字母表外的字符):一个请求都不发,停在原地。 + if (!norm) { + setIncomplete(true); + return; + } + nav(`/device?user_code=${encodeURIComponent(norm)}`); + } + + const server = consoleOrigin(); + const isAgentred = kind === "agentred"; + + const kindChoice = ( +
+ +
+ setKind("agentred")} + > + {t("device.add.kindAgentred")} + + setKind("desktop")} + > + {t("device.add.kindDesktop")} + +
+
+ ); + + return ( + + {/* 步骤条:三格都是按钮,可 Tab 可点,当前步骤对辅助技术可识别(aria-current) */} +
+
    + {STEP_KEYS.map((key, i) => { + const n = i + 1; + const isDone = done.has(n); + const active = step === n; + return ( +
  1. + +
  2. + ); + })} +
+ {onClose && ( +
+ +
+ )} +
+ +
+ {step === 1 && ( + <> + + {kindChoice} + {isAgentred ? ( + <> +
+ +
+ setOS("linux")} + > + {t("device.add.install.osLinux")} + + setOS("macos")} + > + {t("device.add.install.osMacos")} + + setOS("windows")} + > + {t("device.add.install.osWindows")} + +
+
+ + +
+ + {t("device.add.install.manual")} + + +
+ + ) : ( + <> +
+ +
+
+
+
+ +
+ + )} + + )} + + {step === 2 && ( + <> + + {isAgentred ? ( + + ) : ( + + )} + +
+ +
+ + )} + + {step === 3 && ( +
+ +
+ { + setChars(next); + // 用户一动手就撤掉红态,别让他继续瞪着上一次的错误。 + setIncomplete(false); + }} + invalid={incomplete} + describedBy={incomplete ? codeErrorId : undefined} + /> + {incomplete && ( +

+

+ )} +
+

+ {t("device.add.code.handoff")} +

+
+ +
+ + )} +
+
+ ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 414773b9..ad86ea78 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -177,8 +177,71 @@ "body": "Go back to the device, start authorization again, then enter the new code.", "retry": "Enter a new code" }, + "add": { + "open": "Add device", + "collapse": "Collapse guide", + "stepOf": "Step {{n}} of 3", + "copy": "Copy", + "copied": "Copied", + "terminalLabel": "Terminal on that machine", + "kindLabel": "Which kind of device are you adding?", + "kindHint": "No need to add a browser — the one you are using is already in the list", + "kindAgentred": "Compute node (agentred)", + "kindDesktop": "Desktop (Agentre App)", + "steps": { + "install": { + "title": "Install agentred", + "hint": "About a minute", + "done": "Installed" + }, + "login": { + "title": "Sign it in", + "hint": "Prints a device code", + "done": "Signed in" + }, + "code": { + "title": "Enter the device code", + "hint": "Six-character one-time code", + "done": "Authorized" + } + }, + "install": { + "agentredTitle": "Install agentred on that machine", + "agentredDesc": "agentred is the long-running process that executes tasks; once it is installed it can sign in to your account.", + "osLabel": "That machine's operating system", + "osHint": "The architecture is detected automatically", + "osLinux": "Linux", + "osMacos": "macOS", + "osWindows": "Windows", + "powershellLabel": "PowerShell", + "serviceLabel": "Register it as a background service so it stays up after you close the terminal", + "manual": "Download the installer manually", + "desktopTitle": "Install the Agentre desktop app on that computer", + "desktopDesc": "The desktop app has agentred built in; once installed, sign in to the same account from the app.", + "downloadLabel": "Download", + "downloadBody": "Download the installer for that platform from GitHub Releases (macOS .dmg / Windows .exe / Linux .deb).", + "download": "Open the download page", + "next": "Installed — next" + }, + "login": { + "agentredTitle": "Sign it in to this account", + "agentredDesc": "Run this command on that machine. The server address is already this console's own address, so you can copy it as is.", + "agentredTip1": "The command prints a line reading User code: XXXXXX — that is the six-character device code.", + "agentredTip2": "It also tries to open a browser; servers usually have none, so bring that code back here instead.", + "agentredTip3": "The device code expires; run the command again if it does. The approval screen counts down the time left.", + "desktopTitle": "Sign in to the same account from the desktop app", + "desktopDesc": "Open Agentre and go to Settings → Remote → Sign in, using this console as the server address.", + "desktopTip1": "The desktop app shows a six-character device code and tries to open a browser to authorize it.", + "desktopTip2": "If no browser opens, or you would rather approve it on this computer, bring that code to the next step.", + "desktopTip3": "The device code expires; start over from the desktop app if it does. The approval screen counts down the time left.", + "serverLabel": "Server address", + "next": "I have the device code" + }, + "code": { + "handoff": "Submitting opens the approval screen, where you check the code against the device and approve it." + } + }, "manage": { - "empty": "No devices yet.", "loadError": "Could not load your devices. Please try again.", "statusOnline": "Online", "statusOffline": "Offline", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index e4539440..b7a544c4 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -177,8 +177,71 @@ "body": "回到设备上重新发起授权,然后输入新的代码。", "retry": "重新输入代码" }, + "add": { + "open": "添加设备", + "collapse": "收起引导", + "stepOf": "步骤 {{n}} / 3", + "copy": "复制", + "copied": "已复制", + "terminalLabel": "那台机器的终端", + "kindLabel": "要接入哪种设备", + "kindHint": "浏览器不用加 —— 你现在用的这个已经在列表里了", + "kindAgentred": "计算节点(agentred)", + "kindDesktop": "桌面端(Agentre App)", + "steps": { + "install": { + "title": "装上 agentred", + "hint": "约 1 分钟", + "done": "已完成" + }, + "login": { + "title": "让它登录账号", + "hint": "会打印设备码", + "done": "已登录" + }, + "code": { + "title": "输入设备码", + "hint": "6 位一次性码", + "done": "已授权" + } + }, + "install": { + "agentredTitle": "在那台机器上装 agentred", + "agentredDesc": "agentred 是跑任务的常驻进程;装好后它才能登录到你的账号。", + "osLabel": "那台机器的系统", + "osHint": "架构将自动识别", + "osLinux": "Linux", + "osMacos": "macOS", + "osWindows": "Windows", + "powershellLabel": "PowerShell", + "serviceLabel": "注册成后台服务,关掉终端也不掉线", + "manual": "手动下载安装包", + "desktopTitle": "在那台电脑上装 Agentre 桌面端", + "desktopDesc": "桌面端自带 agentred 的能力,装好后在应用里登录同一个账号。", + "downloadLabel": "下载", + "downloadBody": "从 GitHub Releases 下载对应平台的安装包(macOS .dmg / Windows .exe / Linux .deb)。", + "download": "打开下载页", + "next": "我已装好,下一步" + }, + "login": { + "agentredTitle": "让它登录到这个账号", + "agentredDesc": "在那台机器上运行下面这条命令。服务器地址已经填成当前控制台,直接复制即可。", + "agentredTip1": "命令会打印一行 User code: XXXXXX —— 那就是 6 位设备码。", + "agentredTip2": "它会尝试自动打开浏览器;服务器上通常没有浏览器,这时把那 6 位码带回这里。", + "agentredTip3": "设备码会过期;过期就重新运行一次命令。剩余时间由授权确认屏的倒计时给出。", + "desktopTitle": "在桌面端里登录同一个账号", + "desktopDesc": "打开 Agentre,进入设置 → 远端 → 登录,服务器地址填当前控制台。", + "desktopTip1": "桌面端会显示 6 位设备码,并尝试自动打开浏览器完成授权。", + "desktopTip2": "浏览器没打开、或你想在这台电脑上批准,就把那 6 位码填到下一步。", + "desktopTip3": "设备码会过期;过期就在桌面端重新发起一次。剩余时间由授权确认屏的倒计时给出。", + "serverLabel": "服务器地址", + "next": "已拿到设备码" + }, + "code": { + "handoff": "提交后会打开授权确认屏,在那里核对设备上的代码并批准它。" + } + }, "manage": { - "empty": "还没有任何设备。", "loadError": "无法加载你的设备列表,请重试。", "statusOnline": "在线", "statusOffline": "离线", diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index 365019d7..8aaf2831 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { ChevronDown, ChevronUp, Cpu, MonitorX } from "lucide-react"; +import { ChevronDown, ChevronUp, Cpu, Plus } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -15,8 +15,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { EmptyState, RowMenu, StatusMark } from "@/components/console"; +import { RowMenu, StatusMark } from "@/components/console"; import type { StatusTone } from "@/components/console"; +import { AddDeviceGuide } from "@/components/AddDeviceGuide"; import AppShell from "@/components/AppShell"; import { useIsMobile } from "@/components/use-is-mobile"; import { useRelayMachine } from "@/hooks/use-relay"; @@ -535,6 +536,8 @@ export default function Devices() { const [revoking, setRevoking] = useState(null); const [submitting, setSubmitting] = useState(false); const [expanded, setExpanded] = useState>(new Set()); + // 用户点过「添加设备」;空态另有默认展开的规则,见下面的 guideOpen。 + const [guideRequested, setGuideRequested] = useState(false); const [details, setDetails] = useState< Record< number, @@ -641,6 +644,15 @@ export default function Devices() { (d) => d.kind === KIND_AGENTRED && d.online, ); + // 列表到底有几台,只有「加载完 + 不是那种一台都没取到的失败」时才答得上来。 + // 答不上来就既不展开引导、也不渲染入口,更不能改口说「还没有任何设备」—— + // 上面那条错误提示是此刻唯一诚实的内容。 + const listKnown = !loading && !(loadError !== null && devices.length === 0); + const noDevices = listKnown && devices.length === 0; + // 一台都没有时,引导不是「展开态」而是这一页此刻的全部内容:默认展开且不给收起, + // 它取代的正是原来那句孤立的「还没有任何设备。」。 + const guideOpen = listKnown && (noDevices || guideRequested); + return ( {loadErrorText(loadError, t)} )} - {/* 加载失败时只留上面那条错误:不得改口说「还没有任何设备」—— - 那是一句我们此刻答不上来的断言。 */} + {/* 动作行:整页唯一的添加入口。引导展开时它不渲染——同一件事不给两个按钮。 */} + {listKnown && !guideOpen && ( +
+ +
+ )} + + {/* 引导展开在列表上方;空态时它就是这一页的全部内容,所以不给收起。 */} + {guideOpen && ( + setGuideRequested(false)} + /> + )} + {loading ? (

{t("common.loading")}

- ) : loadError !== null && - devices.length === 0 ? null : devices.length === 0 ? ( - - - ) : ( devices.map((d) => (