mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(devices): add browser device pairing flow (#15678)
* ✨ feat: add browser device pairing flow to /settings/devices - Add "Via Browser" tab to ConnectDeviceModal with pairing code display and input - Add "Register this browser as a device" callout card above DeviceList - Support ?pair=<code> URL param to auto-open browser pairing modal with pre-filled code - Improve DeviceList empty state with method cards (Desktop + CLI) - Ship en-US and zh-CN i18n keys for all new browser/sync strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 🔨 fix(devices): fix lint warnings — import sort order and empty catch block * fix(devices): add pair API route and invalidate device list cache - Create /api/devices/pair POST handler that authenticates the user via Better Auth session, validates the code against the user's registered devices via DeviceModel.findByDeviceId, and returns JSON. - Replace the setListKey/key-prop re-mount trick with lambdaQuery.useUtils().device.listDevices.invalidate() so the tRPC React Query cache is properly busted after a successful pair (fixes staleTime: 30s preventing the new device from appearing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ♻️ refactor(devices): drop browser pairing, fix modal close, redesign UI - Remove the "Via Browser" pairing flow entirely: browser tab in ConnectDeviceModal, the "register this browser" callout card, the ?pair=<code> deep-link, and the /api/devices/pair stub route. Only the real Desktop and CLI connection methods remain. - Fix the modal that couldn't be closed: @lobehub/ui Modal closes via onCancel (antd), not onClose — the X button was a no-op. - Redesign the connect modal (segmented tabs, numbered steps, command blocks with copy, security footer) and the empty state (onboarding hero with Desktop/CLI options + capability cards). - Clean up browser/sync i18n keys; add capabilities + footer keys for en-US and zh-CN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 💄 fix(devices): apply card radius — cssVar.borderRadius already has unit The radius tokens (cssVar.borderRadius / borderRadiusLG) already include their unit, so the trailing `px` produced `var(--…)px`, which browsers drop — leaving the cards with sharp corners. Drop the `px` so the cards pick up the same rounded radius as the appearance settings FormGroup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -280,7 +280,33 @@
|
||||
"defaultAgent.title": "New Agent",
|
||||
"devices.actions.edit": "Edit",
|
||||
"devices.actions.remove": "Remove",
|
||||
"devices.capabilities.commands.desc": "Safely execute terminal commands in your environment.",
|
||||
"devices.capabilities.commands.title": "Run commands",
|
||||
"devices.capabilities.files.desc": "Let agents directly access and organize the files on your computer.",
|
||||
"devices.capabilities.files.title": "Read & write local files",
|
||||
"devices.capabilities.title": "What you can do once connected",
|
||||
"devices.capabilities.tools.desc": "Connect local tools to extend what agents can do.",
|
||||
"devices.capabilities.tools.title": "Call system tools",
|
||||
"devices.channel.connected": "Connected {{time}}",
|
||||
"devices.connectWizard.button": "Connect Device",
|
||||
"devices.connectWizard.cli.connectDesc": "Start the background daemon to keep the device online and listening for remote operations.",
|
||||
"devices.connectWizard.cli.connectTitle": "Start the daemon",
|
||||
"devices.connectWizard.cli.installDesc": "Install the LobeHub CLI globally with your preferred package manager to enable device connectivity and management.",
|
||||
"devices.connectWizard.cli.installTitle": "Install the CLI",
|
||||
"devices.connectWizard.cli.loginDesc": "Complete OAuth authorization in your browser to link the CLI with your account.",
|
||||
"devices.connectWizard.cli.loginTitle": "Sign in",
|
||||
"devices.connectWizard.desktop.downloadLink": "Download LobeHub Desktop",
|
||||
"devices.connectWizard.desktop.step1": "Download the desktop app",
|
||||
"devices.connectWizard.desktop.step1Desc": "Visit the LobeHub downloads page and get the app for your operating system.",
|
||||
"devices.connectWizard.desktop.step2": "Sign in and open the device gateway",
|
||||
"devices.connectWizard.desktop.step2Desc": "After signing in, click the device gateway icon in the top-right corner and confirm it's turned on.",
|
||||
"devices.connectWizard.desktop.step3": "Your device appears automatically",
|
||||
"devices.connectWizard.desktop.step3Desc": "The desktop app registers itself as a device on launch — you'll see it in the list once connected.",
|
||||
"devices.connectWizard.footer": "Only device metadata is registered — your data is never accessed.",
|
||||
"devices.connectWizard.method.cli": "Via CLI",
|
||||
"devices.connectWizard.method.desktop": "Via Desktop",
|
||||
"devices.connectWizard.subtitle": "Choose how to connect your computer to LobeHub.",
|
||||
"devices.connectWizard.title": "Connect Device",
|
||||
"devices.currentBadge": "This device",
|
||||
"devices.detail.addDir": "Add directory",
|
||||
"devices.detail.connections": "Connections",
|
||||
@@ -294,7 +320,13 @@
|
||||
"devices.edit.friendlyNamePlaceholder": "A name to recognize this device",
|
||||
"devices.edit.save": "Save",
|
||||
"devices.edit.title": "Edit device",
|
||||
"devices.empty": "No devices yet. Connect one with `lh connect` or by signing in to the desktop app.",
|
||||
"devices.empty.desc": "Once connected, LobeHub agents can read/write files, run commands, and call system tools directly on your computer.",
|
||||
"devices.empty.methodCli.desc": "Install the CLI in your terminal — great for servers or headless machines.",
|
||||
"devices.empty.methodCli.title": "Connect via CLI",
|
||||
"devices.empty.methodDesktop.badge": "Recommended",
|
||||
"devices.empty.methodDesktop.desc": "Download the desktop app, sign in, and your device connects automatically.",
|
||||
"devices.empty.methodDesktop.title": "Connect via Desktop",
|
||||
"devices.empty.title": "Connect your first device",
|
||||
"devices.fallbackBadge": "Unstable identity",
|
||||
"devices.fallbackTooltip": "This device couldn't be identified by its machine ID, so reinstalling the app may create a duplicate entry.",
|
||||
"devices.lastSeen": "Last active {{time}}",
|
||||
|
||||
@@ -280,7 +280,33 @@
|
||||
"defaultAgent.title": "新建助理",
|
||||
"devices.actions.edit": "编辑",
|
||||
"devices.actions.remove": "移除",
|
||||
"devices.capabilities.commands.desc": "在你的环境中安全地执行终端命令。",
|
||||
"devices.capabilities.commands.title": "运行命令",
|
||||
"devices.capabilities.files.desc": "让智能体直接访问并整理你电脑上的文件。",
|
||||
"devices.capabilities.files.title": "读写本地文件",
|
||||
"devices.capabilities.title": "连接后你能做什么",
|
||||
"devices.capabilities.tools.desc": "连接本地工具,扩展智能体的能力边界。",
|
||||
"devices.capabilities.tools.title": "调用系统工具",
|
||||
"devices.channel.connected": "已连接 {{time}}",
|
||||
"devices.connectWizard.button": "连接设备",
|
||||
"devices.connectWizard.cli.connectDesc": "启动后台守护进程,保持设备在线并等待远程操作。",
|
||||
"devices.connectWizard.cli.connectTitle": "启动守护进程",
|
||||
"devices.connectWizard.cli.installDesc": "使用你喜欢的包管理器全局安装 LobeHub CLI,提供设备连接与管理能力。",
|
||||
"devices.connectWizard.cli.installTitle": "安装 CLI",
|
||||
"devices.connectWizard.cli.loginDesc": "在浏览器中完成 OAuth 授权,将 CLI 与你的账号关联。",
|
||||
"devices.connectWizard.cli.loginTitle": "登录账号",
|
||||
"devices.connectWizard.desktop.downloadLink": "下载 LobeHub 桌面端",
|
||||
"devices.connectWizard.desktop.step1": "下载桌面端",
|
||||
"devices.connectWizard.desktop.step1Desc": "前往 LobeHub 下载页获取适用于你操作系统的桌面版应用。",
|
||||
"devices.connectWizard.desktop.step2": "登录并打开设备网关",
|
||||
"devices.connectWizard.desktop.step2Desc": "登录后,点击右上角的设备网关图标,确认开关已打开。",
|
||||
"devices.connectWizard.desktop.step3": "设备自动出现",
|
||||
"devices.connectWizard.desktop.step3Desc": "桌面端启动后会自动注册为设备,连接成功后即可在列表中看到它。",
|
||||
"devices.connectWizard.footer": "仅注册设备信息,不会访问你的数据。",
|
||||
"devices.connectWizard.method.cli": "通过 CLI",
|
||||
"devices.connectWizard.method.desktop": "通过桌面端",
|
||||
"devices.connectWizard.subtitle": "选择一种方式将你的电脑连接到 LobeHub。",
|
||||
"devices.connectWizard.title": "连接设备",
|
||||
"devices.currentBadge": "当前设备",
|
||||
"devices.detail.addDir": "添加目录",
|
||||
"devices.detail.connections": "连接通道",
|
||||
@@ -294,7 +320,13 @@
|
||||
"devices.edit.friendlyNamePlaceholder": "便于识别此设备的名称",
|
||||
"devices.edit.save": "保存",
|
||||
"devices.edit.title": "编辑设备",
|
||||
"devices.empty": "还没有设备。用 `lh connect` 连接,或登录桌面端应用。",
|
||||
"devices.empty.desc": "连接后,LobeHub 上的智能体可以直接在你的电脑上读写文件、运行命令和调用系统工具。",
|
||||
"devices.empty.methodCli.desc": "通过终端安装 CLI,适用于服务器或无桌面环境。",
|
||||
"devices.empty.methodCli.title": "使用 CLI 连接",
|
||||
"devices.empty.methodDesktop.badge": "推荐",
|
||||
"devices.empty.methodDesktop.desc": "下载桌面端,登录后设备自动连接。",
|
||||
"devices.empty.methodDesktop.title": "使用桌面端连接",
|
||||
"devices.empty.title": "连接你的第一台设备",
|
||||
"devices.fallbackBadge": "身份不稳定",
|
||||
"devices.fallbackTooltip": "未能通过机器 ID 识别此设备,重装应用可能会产生重复条目。",
|
||||
"devices.lastSeen": "最近活跃 {{time}}",
|
||||
|
||||
@@ -353,8 +353,50 @@ export default {
|
||||
'devices.edit.friendlyNamePlaceholder': 'A name to recognize this device',
|
||||
'devices.edit.save': 'Save',
|
||||
'devices.edit.title': 'Edit device',
|
||||
'devices.empty':
|
||||
'No devices yet. Connect one with `lh connect` or by signing in to the desktop app.',
|
||||
'devices.capabilities.commands.desc': 'Safely execute terminal commands in your environment.',
|
||||
'devices.capabilities.commands.title': 'Run commands',
|
||||
'devices.capabilities.files.desc':
|
||||
'Let agents directly access and organize the files on your computer.',
|
||||
'devices.capabilities.files.title': 'Read & write local files',
|
||||
'devices.capabilities.title': 'What you can do once connected',
|
||||
'devices.capabilities.tools.desc': 'Connect local tools to extend what agents can do.',
|
||||
'devices.capabilities.tools.title': 'Call system tools',
|
||||
'devices.connectWizard.button': 'Connect Device',
|
||||
'devices.connectWizard.cli.connectDesc':
|
||||
'Start the background daemon to keep the device online and listening for remote operations.',
|
||||
'devices.connectWizard.cli.connectTitle': 'Start the daemon',
|
||||
'devices.connectWizard.cli.installDesc':
|
||||
'Install the LobeHub CLI globally with your preferred package manager to enable device connectivity and management.',
|
||||
'devices.connectWizard.cli.installTitle': 'Install the CLI',
|
||||
'devices.connectWizard.cli.loginDesc':
|
||||
'Complete OAuth authorization in your browser to link the CLI with your account.',
|
||||
'devices.connectWizard.cli.loginTitle': 'Sign in',
|
||||
'devices.connectWizard.desktop.downloadLink': 'Download LobeHub Desktop',
|
||||
'devices.connectWizard.desktop.step1': 'Download the desktop app',
|
||||
'devices.connectWizard.desktop.step1Desc':
|
||||
'Visit the LobeHub downloads page and get the app for your operating system.',
|
||||
'devices.connectWizard.desktop.step2': 'Sign in and open the device gateway',
|
||||
'devices.connectWizard.desktop.step2Desc':
|
||||
"After signing in, click the device gateway icon in the top-right corner and confirm it's turned on.",
|
||||
'devices.connectWizard.desktop.step3': 'Your device appears automatically',
|
||||
'devices.connectWizard.desktop.step3Desc':
|
||||
"The desktop app registers itself as a device on launch — you'll see it in the list once connected.",
|
||||
'devices.connectWizard.footer':
|
||||
'Only device metadata is registered — your data is never accessed.',
|
||||
'devices.connectWizard.method.cli': 'Via CLI',
|
||||
'devices.connectWizard.method.desktop': 'Via Desktop',
|
||||
'devices.connectWizard.subtitle': 'Choose how to connect your computer to LobeHub.',
|
||||
'devices.connectWizard.title': 'Connect Device',
|
||||
'devices.empty.desc':
|
||||
'Once connected, LobeHub agents can read/write files, run commands, and call system tools directly on your computer.',
|
||||
'devices.empty.methodCli.desc':
|
||||
'Install the CLI in your terminal — great for servers or headless machines.',
|
||||
'devices.empty.methodCli.title': 'Connect via CLI',
|
||||
'devices.empty.methodDesktop.badge': 'Recommended',
|
||||
'devices.empty.methodDesktop.desc':
|
||||
'Download the desktop app, sign in, and your device connects automatically.',
|
||||
'devices.empty.methodDesktop.title': 'Connect via Desktop',
|
||||
'devices.empty.title': 'Connect your first device',
|
||||
'devices.fallbackBadge': 'Unstable identity',
|
||||
'devices.fallbackTooltip':
|
||||
"This device couldn't be identified by its machine ID, so reinstalling the app may create a duplicate entry.",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { DOWNLOAD_URL } from '@lobechat/const';
|
||||
import { Button, CopyButton, Flexbox, Icon, Modal, Segmented, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { DownloadIcon, MonitorDownIcon, ShieldCheckIcon, TerminalIcon } from 'lucide-react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
codeBlock: css`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 10px;
|
||||
padding-inline: 14px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
command: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
footer: css`
|
||||
margin-block-start: 4px;
|
||||
padding-block-start: 16px;
|
||||
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
index: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${cssVar.colorPrimary};
|
||||
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
`,
|
||||
line: css`
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
margin-block-start: 4px;
|
||||
background: ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
stepDesc: css`
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
subtitle: css`
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ConnectDeviceModalProps {
|
||||
initialTab?: 'cli' | 'desktop';
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
interface StepProps {
|
||||
children?: React.ReactNode;
|
||||
desc: string;
|
||||
index: number;
|
||||
last?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Step = memo<StepProps>(({ index, title, desc, children, last }) => (
|
||||
<Flexbox horizontal gap={14}>
|
||||
<Flexbox align={'center'}>
|
||||
<span className={styles.index}>{index}</span>
|
||||
{!last && <span className={styles.line} />}
|
||||
</Flexbox>
|
||||
<Flexbox flex={1} gap={4} style={{ paddingBlockEnd: last ? 0 : 24 }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 500 }}>{title}</Text>
|
||||
<Text className={styles.stepDesc}>{desc}</Text>
|
||||
{children && <div style={{ marginBlockStart: 12 }}>{children}</div>}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
));
|
||||
|
||||
const CommandLine = memo<{ command: string }>(({ command }) => (
|
||||
<div className={styles.codeBlock}>
|
||||
<code className={styles.command}>{command}</code>
|
||||
<CopyButton content={command} size={'small'} />
|
||||
</div>
|
||||
));
|
||||
|
||||
const cliCommands = {
|
||||
connect: 'lh connect --daemon',
|
||||
install: 'npm install -g @lobehub/cli',
|
||||
login: 'lh login',
|
||||
};
|
||||
|
||||
const ConnectDeviceModal = memo<ConnectDeviceModalProps>(({ onClose, open, initialTab }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [active, setActive] = useState<'cli' | 'desktop'>(initialTab ?? 'desktop');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActive(initialTab ?? 'desktop');
|
||||
}, [open, initialTab]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={null}
|
||||
open={open}
|
||||
title={t('devices.connectWizard.title')}
|
||||
width={560}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Flexbox gap={20}>
|
||||
<Text className={styles.subtitle}>{t('devices.connectWizard.subtitle')}</Text>
|
||||
|
||||
<Segmented
|
||||
block
|
||||
value={active}
|
||||
options={[
|
||||
{
|
||||
icon: <Icon icon={MonitorDownIcon} />,
|
||||
label: t('devices.connectWizard.method.desktop'),
|
||||
value: 'desktop',
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={TerminalIcon} />,
|
||||
label: t('devices.connectWizard.method.cli'),
|
||||
value: 'cli',
|
||||
},
|
||||
]}
|
||||
onChange={(value) => setActive(value as 'cli' | 'desktop')}
|
||||
/>
|
||||
|
||||
{active === 'desktop' ? (
|
||||
<Flexbox>
|
||||
<Step
|
||||
desc={t('devices.connectWizard.desktop.step1Desc')}
|
||||
index={1}
|
||||
title={t('devices.connectWizard.desktop.step1')}
|
||||
>
|
||||
<a href={DOWNLOAD_URL.default} rel="noreferrer" target="_blank">
|
||||
<Button icon={<Icon icon={DownloadIcon} />} size={'small'} type={'primary'}>
|
||||
{t('devices.connectWizard.desktop.downloadLink')}
|
||||
</Button>
|
||||
</a>
|
||||
</Step>
|
||||
<Step
|
||||
desc={t('devices.connectWizard.desktop.step2Desc')}
|
||||
index={2}
|
||||
title={t('devices.connectWizard.desktop.step2')}
|
||||
/>
|
||||
<Step
|
||||
last
|
||||
desc={t('devices.connectWizard.desktop.step3Desc')}
|
||||
index={3}
|
||||
title={t('devices.connectWizard.desktop.step3')}
|
||||
/>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Flexbox>
|
||||
<Step
|
||||
desc={t('devices.connectWizard.cli.installDesc')}
|
||||
index={1}
|
||||
title={t('devices.connectWizard.cli.installTitle')}
|
||||
>
|
||||
<CommandLine command={cliCommands.install} />
|
||||
</Step>
|
||||
<Step
|
||||
desc={t('devices.connectWizard.cli.loginDesc')}
|
||||
index={2}
|
||||
title={t('devices.connectWizard.cli.loginTitle')}
|
||||
>
|
||||
<CommandLine command={cliCommands.login} />
|
||||
</Step>
|
||||
<Step
|
||||
last
|
||||
desc={t('devices.connectWizard.cli.connectDesc')}
|
||||
index={3}
|
||||
title={t('devices.connectWizard.cli.connectTitle')}
|
||||
>
|
||||
<CommandLine command={cliCommands.connect} />
|
||||
</Step>
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
<Flexbox horizontal align={'center'} className={styles.footer} gap={8}>
|
||||
<Icon icon={ShieldCheckIcon} size={14} style={{ color: cssVar.colorPrimary }} />
|
||||
{t('devices.connectWizard.footer')}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectDeviceModal.displayName = 'ConnectDeviceModal';
|
||||
|
||||
export default ConnectDeviceModal;
|
||||
@@ -1,36 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { Flexbox, Skeleton, Text } from '@lobehub/ui';
|
||||
import { Flexbox, Icon, Skeleton, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FolderCogIcon,
|
||||
type LucideIcon,
|
||||
MonitorDownIcon,
|
||||
ShieldCheckIcon,
|
||||
TerminalIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import ConnectDeviceModal from './ConnectDeviceModal';
|
||||
import DeviceDetailPanel from './DeviceDetailPanel';
|
||||
import DeviceItem from './DeviceItem';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
badge: css`
|
||||
padding-block: 1px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${cssVar.colorPrimary};
|
||||
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
`,
|
||||
capabilityCard: css`
|
||||
padding: 16px;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${cssVar.colorPrimaryBorder};
|
||||
}
|
||||
`,
|
||||
capabilityIcon: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
color: ${cssVar.colorText};
|
||||
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
detailCol: css`
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
`,
|
||||
empty: css`
|
||||
padding-block: 48px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
emptyCard: css`
|
||||
overflow: hidden;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
emptyHero: css`
|
||||
padding-block: 40px;
|
||||
padding-inline: 32px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
text-align: center;
|
||||
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
heroIcon: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
|
||||
color: ${cssVar.colorPrimary};
|
||||
|
||||
background: ${cssVar.colorPrimaryBg};
|
||||
`,
|
||||
listCol: css`
|
||||
min-width: 0;
|
||||
border: 1px solid ${cssVar.colorBorderSecondary};
|
||||
border-radius: ${cssVar.borderRadiusLG};
|
||||
`,
|
||||
option: css`
|
||||
cursor: pointer;
|
||||
padding: 20px;
|
||||
background: ${cssVar.colorBgContainer};
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
}
|
||||
`,
|
||||
optionGrid: css`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: ${cssVar.colorBorderSecondary};
|
||||
`,
|
||||
optionIcon: css`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: ${cssVar.borderRadius};
|
||||
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
subtitle: css`
|
||||
font-size: 13px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ConnectOptionProps {
|
||||
badge?: string;
|
||||
desc: string;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ConnectOption = memo<ConnectOptionProps>(({ icon, title, desc, badge, onClick }) => (
|
||||
<Flexbox horizontal align={'flex-start'} className={styles.option} gap={14} onClick={onClick}>
|
||||
<span className={styles.optionIcon}>
|
||||
<Icon icon={icon} size={20} />
|
||||
</span>
|
||||
<Flexbox flex={1} gap={2}>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 500 }}>{title}</Text>
|
||||
{badge && <span className={styles.badge}>{badge}</span>}
|
||||
</Flexbox>
|
||||
<Text className={styles.subtitle} style={{ fontSize: 12 }}>
|
||||
{desc}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Icon icon={ChevronRightIcon} size={16} style={{ color: cssVar.colorTextQuaternary }} />
|
||||
</Flexbox>
|
||||
));
|
||||
|
||||
const DeviceList = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const { data: devices, isLoading } = lambdaQuery.device.listDevices.useQuery(undefined, {
|
||||
@@ -48,14 +178,93 @@ const DeviceList = memo(() => {
|
||||
// No device is selected by default — the detail panel only appears once the
|
||||
// user clicks a row.
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [connectTab, setConnectTab] = useState<'cli' | 'desktop'>();
|
||||
|
||||
const openConnect = (tab: 'cli' | 'desktop') => setConnectTab(tab);
|
||||
|
||||
if (isLoading) return <Skeleton active paragraph={{ rows: 4 }} title={false} />;
|
||||
|
||||
if (!devices || devices.length === 0)
|
||||
return (
|
||||
<Flexbox align={'center'} className={styles.empty} justify={'center'}>
|
||||
<Text type={'secondary'}>{t('devices.empty')}</Text>
|
||||
</Flexbox>
|
||||
<>
|
||||
<Flexbox gap={32}>
|
||||
{/* Onboarding card: hero + the two real connection methods */}
|
||||
<Flexbox className={styles.emptyCard}>
|
||||
<Flexbox align={'center'} className={styles.emptyHero} gap={12}>
|
||||
<span className={styles.heroIcon}>
|
||||
<Icon icon={MonitorDownIcon} size={28} />
|
||||
</span>
|
||||
<Text style={{ fontSize: 18, fontWeight: 600 }}>{t('devices.empty.title')}</Text>
|
||||
<Text className={styles.subtitle} style={{ maxWidth: 440 }}>
|
||||
{t('devices.empty.desc')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
|
||||
<div className={styles.optionGrid}>
|
||||
<ConnectOption
|
||||
badge={t('devices.empty.methodDesktop.badge')}
|
||||
desc={t('devices.empty.methodDesktop.desc')}
|
||||
icon={MonitorDownIcon}
|
||||
title={t('devices.empty.methodDesktop.title')}
|
||||
onClick={() => openConnect('desktop')}
|
||||
/>
|
||||
<ConnectOption
|
||||
desc={t('devices.empty.methodCli.desc')}
|
||||
icon={TerminalIcon}
|
||||
title={t('devices.empty.methodCli.title')}
|
||||
onClick={() => openConnect('cli')}
|
||||
/>
|
||||
</div>
|
||||
</Flexbox>
|
||||
|
||||
{/* Capabilities unlocked once a device is connected */}
|
||||
<Flexbox gap={16}>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
<Icon icon={ShieldCheckIcon} size={16} style={{ color: cssVar.colorPrimary }} />
|
||||
<Text style={{ fontSize: 14, fontWeight: 500 }}>
|
||||
{t('devices.capabilities.title')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={16}>
|
||||
{[
|
||||
{
|
||||
desc: t('devices.capabilities.files.desc'),
|
||||
icon: FolderCogIcon,
|
||||
title: t('devices.capabilities.files.title'),
|
||||
},
|
||||
{
|
||||
desc: t('devices.capabilities.commands.desc'),
|
||||
icon: TerminalIcon,
|
||||
title: t('devices.capabilities.commands.title'),
|
||||
},
|
||||
{
|
||||
desc: t('devices.capabilities.tools.desc'),
|
||||
icon: ZapIcon,
|
||||
title: t('devices.capabilities.tools.title'),
|
||||
},
|
||||
].map((cap) => (
|
||||
<Flexbox className={styles.capabilityCard} flex={1} gap={12} key={cap.title}>
|
||||
<span className={styles.capabilityIcon}>
|
||||
<Icon icon={cap.icon} size={18} />
|
||||
</span>
|
||||
<Flexbox gap={2}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 500 }}>{cap.title}</Text>
|
||||
<Text className={styles.subtitle} style={{ fontSize: 12 }}>
|
||||
{cap.desc}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
|
||||
<ConnectDeviceModal
|
||||
initialTab={connectTab}
|
||||
open={!!connectTab}
|
||||
onClose={() => setConnectTab(undefined)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const selected = selectedId ? devices.find((d) => d.deviceId === selectedId) : undefined;
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Button, Icon } from '@lobehub/ui';
|
||||
import { MonitorUpIcon } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SettingHeader from '@/routes/(main)/settings/features/SettingHeader';
|
||||
|
||||
import ConnectDeviceModal from './features/ConnectDeviceModal';
|
||||
import DeviceList from './features/DeviceList';
|
||||
|
||||
const Page = memo(() => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [connectModalOpen, setConnectModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader title={t('devices.title')} />
|
||||
<SettingHeader
|
||||
title={t('devices.title')}
|
||||
extra={
|
||||
<Button
|
||||
icon={<Icon icon={MonitorUpIcon} />}
|
||||
size={'small'}
|
||||
onClick={() => setConnectModalOpen(true)}
|
||||
>
|
||||
{t('devices.connectWizard.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DeviceList />
|
||||
|
||||
<ConnectDeviceModal open={connectModalOpen} onClose={() => setConnectModalOpen(false)} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user