- update axios to 1.7.4

- update menu bar: add tips on how to upload image
- optimizing i18n code
This commit is contained in:
wj-xiao
2024-08-22 14:54:07 +08:00
parent aae370fc2b
commit ea39c024ac
21 changed files with 4074 additions and 3121 deletions

View File

@@ -1,5 +1,7 @@
# NanoKVM Frontend
This is NanoKVM web project. For more documentation, please refer to the [Wiki](https://wiki.sipeed.com/nanokvm).
## Structure
```shell
@@ -7,15 +9,15 @@ src
├── api // backend api
├── assets // static resources
├── components // public components
├── i18n // i18n
├── i18n // language resources
├── jotai // Global jotai variables
├── lib // lib
├── pages // web pages
├── lib // util libs
├── pages // web pages
│ ├── auth // login and password
│ ├── desktop // remote desktop
│ └── terminal // web terminal
├── router.tsx // routers
└── types // types
├── router.tsx // routers
└── types // types
```
## Deployment
@@ -28,7 +30,9 @@ pnpm install
pnpm build
```
Once compilation is complete, the `dist` folder will be generated. Rename the folder to `web` and upload it to the `/kvmapp/server/` directory in NanoKVM.
Then, restart the service by executing `/etc/init.d/S95nanokvm` in NanoKVM.
1. After the compilation is complete, the `dist` folder will be generated.
2. Rename the folder to `web`.
3. Upload `web` to `/kvmapp/server/` in NanoKVM.
4. Restart the service by executing `/etc/init.d/S95nanokvm restart` in NanoKVM.
Additionally, browser may have old version cache. If you can't open the page, try a force refresh or clear the cache.

View File

@@ -1,5 +1,7 @@
# NanoKVM 前端页面
NanoKVM 前端页面的代码。更多文档请参考 [Wiki](https://wiki.sipeed.com/nanokvm) 。
## 目录结构
```shell
@@ -28,8 +30,10 @@ pnpm install
pnpm build
```
编译完成后会生成 `dist` 文件夹,将该文件夹重命名为 `web` 并上传到 NanoKVM 的 `/kvmapp/server/` 目录下。
然后在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务即可。
1. 编译完成后会生成 `dist` 文件夹
2. 将该文件夹重命名为 `web`
3.`web` 文件夹上传到 NanoKVM 的 `/kvmapp/server/` 目录下;
4. 在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务。
注意:更新 web 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。

View File

@@ -15,7 +15,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.19.2",
"axios": "^1.7.2",
"axios": "^1.7.4",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.11.5",

6171
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,6 @@ export const en = {
changePassword: 'Change Password',
logout: 'Logout',
keyboard: 'Keyboard',
images: 'Images',
loading: 'Loading',
empty: 'Nothing Found',
settings: 'Settings',
showMouse: 'Show mouse',
hideMouse: 'Hide mouse',
@@ -70,6 +67,26 @@ export const en = {
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
resetHid: 'Reset HID'
},
image: {
title: 'Images',
loading: 'Loading...',
empty: 'Nothing Found',
tips: {
title: 'How to upload',
usb1: 'Connect the NanoKVM to your computer via USB.',
usb2: 'Ensure that the Virtual USB is mounted (Settings - Virtual USB).',
usb3: 'Open the virtual usb disk on your computer and copy the image file to the root directory of the usb disk.',
scp1: 'Make sure the NanoKVM and your computer are on the same local network.',
scp2: 'Open a terminal on your computer and use the SCP command to upload the image file to the /data directory on the NanoKVM.',
scp3: 'Example: scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF Card',
tf1: 'This method is supported on Linux system',
tf2: 'Get TF card from the NanoKVM (for the FULL version, disassemble the case first).',
tf3: 'Insert the TF card into a card reader and connect it to your computer.',
tf4: 'Copy the image file to the /data directory on the TF card.',
tf5: 'Insert the TF card into the NanoKVM.'
}
},
script: {
title: 'Script',
upload: 'Upload',

View File

@@ -4,9 +4,6 @@ export const fr = {
changePassword: 'Changer le mot de passe',
logout: 'Déconnexion',
keyboard: 'Clavier',
images: 'Images',
loading: 'Chargement',
empty: 'Vide',
settings: 'Réglages',
showMouse: 'Afficher la souris',
hideMouse: 'Masquer la souris',
@@ -15,9 +12,10 @@ export const fr = {
powerShort: 'Power (appui court)',
powerLong: 'Power (appui long)',
hddLed: 'HDD LED',
checkLibFailed: 'Impossible de vérifier la bibliothèque d\'exécution, veuillez réessayer',
updateLibFailed: 'Impossible de mettre à jour la bibliothèque d\'exécution, veuillez réessayer',
updatingLib: 'Mise à jour de la bibliothèque d\'exécution. Veuillez rafraîchir la page après la mise à jour.',
checkLibFailed: "Impossible de vérifier la bibliothèque d'exécution, veuillez réessayer",
updateLibFailed: "Impossible de mettre à jour la bibliothèque d'exécution, veuillez réessayer",
updatingLib:
"Mise à jour de la bibliothèque d'exécution. Veuillez rafraîchir la page après la mise à jour.",
checkForUpdate: 'Chercher des mises à jour',
head: {
desktop: 'Bureau à distance',
@@ -27,23 +25,25 @@ export const fr = {
},
auth: {
login: 'Connexion',
placeholderUsername: 'Veuillez entrer votre nom d\'utilisateur',
placeholderUsername: "Veuillez entrer votre nom d'utilisateur",
placeholderPassword: 'Veuillez entrer votre mot de passe',
placeholderPassword2: 'Veuillez entrer votre mot de passe à nouveau',
noEmptyUsername: 'Le nom d\'utilisateur ne peut pas être vide',
noEmptyUsername: "Le nom d'utilisateur ne peut pas être vide",
noEmptyPassword: 'Le mot de passe ne peut pas être vide',
noAccount: 'Impossible de récupérer les informations de l\'utilisateur, veuillez rafraîchir la page ou réinitialiser le mot de passe',
invalidUser: 'Nom d\'utilisateur ou mot de passe invalide',
noAccount:
"Impossible de récupérer les informations de l'utilisateur, veuillez rafraîchir la page ou réinitialiser le mot de passe",
invalidUser: "Nom d'utilisateur ou mot de passe invalide",
error: 'Erreur inattendue',
changePassword: 'Changer le mot de passe',
differentPassword: 'Les mots de passe ne correspondent pas',
illegalUsername: 'Le nom d\'utilisateur contient des caractères illégaux',
illegalUsername: "Le nom d'utilisateur contient des caractères illégaux",
illegalPassword: 'Le mot de passe contient des caractères illégaux',
forgetPassword: 'Mot de passe oublié',
resetPassword: 'Réinitialiser le mot de passe',
reset1: 'Si vous avez oublié le mot de passe, veuillez suivre les étapes pour le réinitialiser:',
reset2: '1. Connectez-vous à l\'appareil NanoKVM via SSH;',
reset3: '2. Supprimez le fichier de l\'appareil: ',
reset1:
'Si vous avez oublié le mot de passe, veuillez suivre les étapes pour le réinitialiser:',
reset2: "1. Connectez-vous à l'appareil NanoKVM via SSH;",
reset3: "2. Supprimez le fichier de l'appareil: ",
reset4: '3. Utilisez le compte par défaut pour vous connecter: ',
ok: 'Se connecter',
cancel: 'Annuler'
@@ -67,15 +67,36 @@ export const fr = {
mode: 'Mode de la souris',
absolute: 'Mode absolu',
relative: 'Mode relatif',
requestPointer: 'Utilisation du mode relatif. Veuillez cliquer sur le bureau pour obtenir le pointeur de la souris.',
resetHid: 'Réinitialiser le périphérique HID',
requestPointer:
'Utilisation du mode relatif. Veuillez cliquer sur le bureau pour obtenir le pointeur de la souris.',
resetHid: 'Réinitialiser le périphérique HID'
},
image: {
title: 'Images',
loading: 'Chargement',
empty: 'Vide',
tips: {
title: 'Comment télécharger',
usb1: 'Connectez le NanoKVM à votre ordinateur via USB.',
usb2: 'Assurez-vous que le disque virtuel est monté (Paramètres - Disque virtuel).',
usb3: 'Ouvrez le disque virtuel sur votre ordinateur et copiez le fichier image dans le répertoire racine du disque.',
scp1: 'Assurez-vous que le NanoKVM et votre ordinateur sont sur le même réseau local.',
scp2: 'Ouvrez un terminal sur votre ordinateur et utilisez la commande SCP pour télécharger le fichier image dans le répertoire /data du NanoKVM.',
scp3: 'Exemple : scp chemin-de-votre-image root@ip-de-votre-nanokvm:/data',
tfCard: 'Carte TF',
tf1: 'Cette méthode est adaptée aux systèmes Linux.',
tf2: 'Retirez la carte TF du NanoKVM (Pour la version FULL, il est nécessaire de retirer le boîtier).',
tf3: 'Insérez la carte TF dans un lecteur de carte et connectez-la à votre ordinateur.',
tf4: 'Copiez le fichier image dans le répertoire /data de la carte TF sur votre ordinateur.',
tf5: 'Réinsérez la carte TF dans le NanoKVM.'
}
},
script: {
title: 'Script',
upload: 'Téléverser',
run: 'Executer',
runBackground: 'Executer en arrière-plan',
runFailed: 'Échec de l\'exécution',
runFailed: "Échec de l'exécution",
attention: 'Attention',
delDesc: 'Etes-vous sûr de vouloir supprimer ce fichier?',
confirm: 'Oui',
@@ -95,7 +116,7 @@ export const fr = {
wol: {
sending: 'Envoi de la commande...',
sent: 'Commande envoyée',
input: 'Veuillez entrer l\'adresse MAC',
input: "Veuillez entrer l'adresse MAC",
ok: 'Ok'
},
about: {
@@ -103,11 +124,11 @@ export const fr = {
information: 'Informations',
ip: 'IP',
mdns: 'mDNS',
firmware: 'Version de l\'application',
image: 'Version de l\'image',
deviceKey: 'Clé de l\'appareil',
firmware: "Version de l'application",
image: "Version de l'image",
deviceKey: "Clé de l'appareil",
queryFailed: 'Echec de la requête',
community: 'Communauté',
community: 'Communauté'
},
update: {
title: 'Vérifier les mises à jour',
@@ -125,14 +146,14 @@ export const fr = {
},
tailscale: {
loading: 'Chargement...',
notInstalled: 'Tailscale non trouvé! Veuillez l\'installer.',
notInstalled: "Tailscale non trouvé! Veuillez l'installer.",
install: 'Installer',
installing: 'Installation...',
urlPeriod: 'L\'URL est valide pendant 10 minutes',
urlPeriod: "L'URL est valide pendant 10 minutes",
installed: 'Tailscale est déjà installé. Veuillez vous connecter.',
login: 'Connexion',
failed: 'Installation échouée',
retry: 'Veuillez rafraîchir et réessayer. Ou essayez d\'installer manuellement',
retry: "Veuillez rafraîchir et réessayer. Ou essayez d'installer manuellement",
download: 'Télécharger le',
package: 'installation package',
unzip: 'et décompressez-le',

View File

@@ -4,19 +4,35 @@ import { initReactI18next } from 'react-i18next';
import { getLanguage } from '@/lib/localstorage.ts';
import { en } from './en';
import { zh } from './zh';
import { fr } from './fr';
import { zh } from './zh';
const lng = getLanguage();
const resources = { en, fr, zh };
function getCurrentLanguage(): string {
const languages = Object.keys(resources);
const cookieLng = getLanguage();
if (cookieLng && languages.includes(cookieLng)) {
return cookieLng;
}
const navigatorLng = navigator.language.split('-')[0];
if (languages.includes(navigatorLng)) {
return navigatorLng;
}
return 'en';
}
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.use(initReactI18next)
.init({
resources: { zh, en, fr },
lng,
resources: resources,
lng: getCurrentLanguage(),
fallbackLng: 'en',
interpolation: {
escapeValue: false // react already safes from xss
escapeValue: false
}
})
.then();

View File

@@ -5,9 +5,6 @@ export const zh = {
changePassword: '修改密码',
logout: '登出',
keyboard: '键盘',
images: '镜像',
loading: '加载中',
empty: '无镜像文件',
settings: '设置',
showMouse: '显示鼠标',
hideMouse: '隐藏鼠标',
@@ -69,6 +66,26 @@ export const zh = {
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
resetHid: '重置 HID'
},
image: {
title: '镜像',
loading: '加载中',
empty: '无镜像文件',
tips: {
title: '如何上传',
usb1: '将 NanoKVM 通过 USB 连接到你的电脑;',
usb2: '确保已经挂载了虚拟U盘设置 - 虚拟U盘',
usb3: '在电脑上打开虚拟U盘将镜像文件拷贝到虚拟U盘的根目录下。',
scp1: '确保 NanoKVM 和你的电脑在同一个局域网内;',
scp2: '在电脑上打开终端软件,使用 SCP 命令将镜像文件上传到 NanoKVM 的 /data 目录。',
scp3: '示例scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF 卡',
tf1: '该方式适用于 Linux 系统',
tf2: '将 TF 卡从 NanoKVM 中取出FULL 版本需要先拆开外壳);',
tf3: '将 TF 卡插入读卡器并连接到你的电脑;',
tf4: '从电脑上拷贝镜像文件到 TF 卡的 /data 目录下;',
tf5: '将 TF 卡重新插入 NanoKVM。'
}
},
script: {
title: '脚本',
upload: '上传',

View File

@@ -8,7 +8,7 @@ const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
type Item = {
type ItemWithExpiry = {
value: string;
expiry: number;
};
@@ -17,7 +17,7 @@ type Item = {
function setWithExpiry(key: string, value: string, ttl: number) {
const now = new Date();
const item: Item = {
const item: ItemWithExpiry = {
value: value,
expiry: now.getTime() + ttl
};
@@ -30,7 +30,7 @@ function getWithExpiry(key: string) {
const itemStr = localStorage.getItem(key);
if (!itemStr) return null;
const item: Item = JSON.parse(itemStr);
const item: ItemWithExpiry = JSON.parse(itemStr);
const now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(key);
@@ -41,20 +41,7 @@ function getWithExpiry(key: string) {
}
export function getLanguage() {
const lang = localStorage.getItem(LANGUAGE_KEY);
if (lang) return lang;
const language = navigator.language;
switch (true) {
case language.indexOf('zh') > -1:
return 'zh';
case language.indexOf('fr') > -1:
return 'fr';
default:
return 'en'; // Default language
}
return localStorage.getItem(LANGUAGE_KEY);
}
export function setLanguage(language: string) {

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { FileBoxIcon, LoaderCircleIcon, PackageIcon, PackageSearchIcon, XIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage.ts';
import { client } from '@/lib/websocket.ts';
type ImagesProps = {
setIsMounted: (isMounted: boolean) => void;
};
export const Images = ({ setIsMounted }: ImagesProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [images, setImages] = useState<string[]>([]);
const [mountingImage, setMountingImage] = useState('');
const [mountedImage, setMountedImage] = useState('');
useEffect(() => {
getImages();
}, []);
function getImages() {
if (isLoading) return;
setIsLoading(true);
api
.getImages()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const files = rsp.data?.files;
if (files?.length > 0) {
setImages(files);
getMountedImage();
} else {
setImages([]);
}
})
.finally(() => {
setIsLoading(false);
});
}
async function getMountedImage() {
try {
const rsp = await api.getMountedImage();
if (rsp.code !== 0) return;
const file = rsp.data?.file;
setIsMounted(!!file);
} catch (e) {
console.log(e);
}
}
function mountImage(image: string) {
if (mountingImage) return;
setMountingImage(image);
client.close();
const filename = mountedImage === image ? '' : image;
api
.mountImage(filename)
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
setMountedImage(filename);
setIsMounted(!!filename);
})
.finally(() => {
setMountingImage('');
client.connect();
});
}
return (
<>
{isLoading ? (
// loading
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('image.loading')}</span>
</div>
) : images.length === 0 ? (
// no image
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('image.empty')}</span>
</div>
) : (
// image list
images.map((image) => (
<div
key={image}
className={clsx(
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
{ 'text-blue-500': mountedImage === image }
)}
onClick={() => mountImage(image)}
>
{/* image state icon */}
{mountedImage === image ? (
<div className="h-[18px] w-[18px] group-hover:text-red-500">
<PackageIcon size={18} className="block group-hover:hidden" />
<XIcon size={18} className="hidden group-hover:block" />
</div>
) : mountingImage === image ? (
<div className="h-[18px] w-[18px]">
<LoaderCircleIcon className="animate-spin" size={18} />
</div>
) : (
<div className="h-[18px] w-[18px]">
<FileBoxIcon size={18} />
</div>
)}
<span className="break-all">{image.replace(/^.*[\\/]/, '')}</span>
</div>
))
)}
</>
);
};

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import { DiscIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getMountedImage } from '@/api/storage.ts';
import { Images } from './images.tsx';
import { Tips } from './tips.tsx';
export const Image = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
getMountedImage().then((rsp) => {
if (rsp.code !== 0) return;
setIsMounted(!!rsp.data?.file);
});
}, []);
const content = (
<div className="min-w-[280px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('image.title')}</span>
<Tips />
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{isPopoverOpen && <Images setIsMounted={setIsMounted} />}
</div>
);
return (
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div
className={clsx(
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300'
)}
>
<DiscIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,81 @@
import { useState } from 'react';
import type { CollapseProps } from 'antd';
import { Collapse, Modal } from 'antd';
import { CircleHelpIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const Tips = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const items: CollapseProps['items'] = [
{
key: '1',
label: 'USB',
children: (
<ul className="list-decimal">
<li>{t('image.tips.usb1')}</li>
<li>{t('image.tips.usb2')}</li>
<li>{t('image.tips.usb3')}</li>
</ul>
)
},
{
key: '2',
label: 'SCP',
children: (
<ul className="list-decimal">
<li>{t('image.tips.scp1')}</li>
<li>{t('image.tips.scp2')}</li>
<li>{t('image.tips.scp3')}</li>
</ul>
)
},
{
key: '3',
label: t('image.tips.tfCard'),
children: (
<>
<p className="pl-5 text-sm text-neutral-400">{t('image.tips.tf1')}</p>
<ul className="list-decimal">
<li>{t('image.tips.tf2')}</li>
<li>{t('image.tips.tf3')}</li>
<li>{t('image.tips.tf4')}</li>
<li>{t('image.tips.tf5')}</li>
</ul>
</>
)
}
];
return (
<>
<div
className="flex cursor-pointer items-center space-x-1 text-neutral-400"
onClick={() => setIsModalOpen(true)}
>
<CircleHelpIcon size={16} />
<span className="text-sm text-neutral-500 hover:text-neutral-400">
{t('image.tips.title')}
</span>
</div>
<Modal
title={t('image.tips.title')}
open={isModalOpen}
width={350}
footer={null}
centered
onCancel={() => setIsModalOpen(false)}
>
<Collapse
accordion
items={items}
bordered={false}
defaultActiveKey={['1']}
style={{ backgroundColor: 'transparent' }}
/>
</Modal>
</>
);
};

View File

@@ -2,13 +2,13 @@ import { useState } from 'react';
import clsx from 'clsx';
import { MenuIcon } from 'lucide-react';
import { Image } from './image';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { Power } from './power';
import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
import { Storage } from './storage';
import { Terminal } from './terminal';
import { Wol } from './wol';
@@ -39,7 +39,7 @@ export const MenuPhone = () => {
<Screen />
<Keyboard />
<Mouse />
<Storage />
<Image />
<Script />
<Terminal />
<Wol />

View File

@@ -8,43 +8,33 @@ import { setLanguage } from '@/lib/localstorage.ts';
export const Language = () => {
const { t, i18n } = useTranslation();
function changeLanguage(language: string) {
if (i18n.language === language) return;
i18n.changeLanguage(language).then();
setLanguage(language);
function changeLanguage(lng: string) {
if (i18n.language === lng) return;
i18n.changeLanguage(lng);
setLanguage(lng);
}
const languageItems = [
{ lng: 'en', name: 'English' },
{ lng: 'fr', name: 'Français' },
{ lng: 'zh', name: '中文' }
];
const content = (
<>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('en')}
>
English
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'fr' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('fr')}
>
Français
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('zh')}
>
</div>
{languageItems.map((item) => (
<div
key={item.lng}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === item.lng ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage(item.lng)}
>
{item.name}
</div>
))}
</>
);

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { FileBoxIcon, LoaderCircleIcon, PackageIcon, PackageSearchIcon, XIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage.ts';
import { client } from '@/lib/websocket.ts';
type ImagesProps = {
setIsMounted: (isMounted: boolean) => void;
};
export const Images = ({ setIsMounted }: ImagesProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [images, setImages] = useState<string[]>([]);
const [mountingImage, setMountingImage] = useState('');
const [mountedImage, setMountedImage] = useState('');
useEffect(() => {
getImages();
}, []);
function getImages() {
if (isLoading) return;
setIsLoading(true);
api
.getImages()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const files = rsp.data?.files;
if (files?.length > 0) {
setImages(files);
getMountedImage();
} else {
setImages([]);
}
})
.finally(() => {
setIsLoading(false);
});
}
async function getMountedImage() {
try {
const rsp = await api.getMountedImage();
if (rsp.code !== 0) return;
const file = rsp.data?.file;
setIsMounted(!!file);
} catch (e) {
console.log(e);
}
}
function mountImage(image: string) {
if (mountingImage) return;
setMountingImage(image);
client.close();
const filename = mountedImage === image ? '' : image;
api
.mountImage(filename)
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
setMountedImage(filename);
setIsMounted(!!filename);
})
.finally(() => {
setMountingImage('');
client.connect();
});
}
return (
<>
{isLoading ? (
// loading
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('image.loading')}</span>
</div>
) : images.length === 0 ? (
// no image
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('image.empty')}</span>
</div>
) : (
// image list
images.map((image) => (
<div
key={image}
className={clsx(
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
{ 'text-blue-500': mountedImage === image }
)}
onClick={() => mountImage(image)}
>
{/* image state icon */}
{mountedImage === image ? (
<div className="h-[18px] w-[18px] group-hover:text-red-500">
<PackageIcon size={18} className="block group-hover:hidden" />
<XIcon size={18} className="hidden group-hover:block" />
</div>
) : mountingImage === image ? (
<div className="h-[18px] w-[18px]">
<LoaderCircleIcon className="animate-spin" size={18} />
</div>
) : (
<div className="h-[18px] w-[18px]">
<FileBoxIcon size={18} />
</div>
)}
<span className="break-all">{image.replace(/^.*[\\/]/, '')}</span>
</div>
))
)}
</>
);
};

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import { DiscIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getMountedImage } from '@/api/storage.ts';
import { Images } from './images.tsx';
import { Tips } from './tips.tsx';
export const Image = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
getMountedImage().then((rsp) => {
if (rsp.code !== 0) return;
setIsMounted(!!rsp.data?.file);
});
}, []);
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('image.title')}</span>
<Tips />
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{isPopoverOpen && <Images setIsMounted={setIsMounted} />}
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div
className={clsx(
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
isMounted ? 'text-blue-500' : 'text-neutral-300'
)}
>
<DiscIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,81 @@
import { useState } from 'react';
import type { CollapseProps } from 'antd';
import { Collapse, Modal } from 'antd';
import { CircleHelpIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export const Tips = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const items: CollapseProps['items'] = [
{
key: '1',
label: 'USB',
children: (
<ul className="list-decimal">
<li>{t('image.tips.usb1')}</li>
<li>{t('image.tips.usb2')}</li>
<li>{t('image.tips.usb3')}</li>
</ul>
)
},
{
key: '2',
label: 'SCP',
children: (
<ul className="list-decimal">
<li>{t('image.tips.scp1')}</li>
<li>{t('image.tips.scp2')}</li>
<li>{t('image.tips.scp3')}</li>
</ul>
)
},
{
key: '3',
label: t('image.tips.tfCard'),
children: (
<>
<p className="pl-5 text-sm text-neutral-400">{t('image.tips.tf1')}</p>
<ul className="list-decimal">
<li>{t('image.tips.tf2')}</li>
<li>{t('image.tips.tf3')}</li>
<li>{t('image.tips.tf4')}</li>
<li>{t('image.tips.tf5')}</li>
</ul>
</>
)
}
];
return (
<>
<div
className="flex cursor-pointer items-center space-x-1 text-neutral-400"
onClick={() => setIsModalOpen(true)}
>
<CircleHelpIcon size={16} />
<span className="text-sm text-neutral-500 hover:text-neutral-400">
{t('image.tips.title')}
</span>
</div>
<Modal
title={t('image.tips.title')}
open={isModalOpen}
width={520}
footer={null}
centered
onCancel={() => setIsModalOpen(false)}
>
<Collapse
accordion
items={items}
bordered={false}
defaultActiveKey={['1']}
style={{ backgroundColor: 'transparent' }}
/>
</Modal>
</>
);
};

View File

@@ -4,13 +4,13 @@ import clsx from 'clsx';
import { MenuIcon, XIcon } from 'lucide-react';
import { Fullscreen } from './fullscreen';
import { Image } from './image';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { Power } from './power';
import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
import { Storage } from './storage';
import { Terminal } from './terminal';
import { Wol } from './wol';
@@ -35,7 +35,7 @@ export const Menu = () => {
<Mouse />
<Divider type="vertical" />
<Storage />
<Image />
<Script />
<Terminal />
<Wol />

View File

@@ -114,9 +114,7 @@ export const Script = () => {
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
</div>
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
<input
ref={inputRef}

View File

@@ -8,43 +8,33 @@ import { setLanguage } from '@/lib/localstorage.ts';
export const Language = () => {
const { t, i18n } = useTranslation();
function changeLanguage(language: string) {
if (i18n.language === language) return;
i18n.changeLanguage(language).then();
setLanguage(language);
function changeLanguage(lng: string) {
if (i18n.language === lng) return;
i18n.changeLanguage(lng);
setLanguage(lng);
}
const languageItems = [
{ lng: 'en', name: 'English' },
{ lng: 'fr', name: 'Français' },
{ lng: 'zh', name: '中文' }
];
const content = (
<>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('en')}
>
English
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'fr' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('fr')}
>
Français
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('zh')}
>
</div>
{languageItems.map((item) => (
<div
key={item.lng}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === item.lng ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage(item.lng)}
>
{item.name}
</div>
))}
</>
);

View File

@@ -1,164 +0,0 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import {
DiscIcon,
FileBoxIcon,
LoaderCircleIcon,
PackageIcon,
PackageSearchIcon,
XIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import { client } from '@/lib/websocket.ts';
export const Storage = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<string[]>([]);
const [mountingFile, setMountingFile] = useState('');
const [mountedFile, setMountedFile] = useState('');
useEffect(() => {
getFiles();
}, []);
function handleOpenChange(open: boolean) {
if (open) {
getFiles();
}
setIsPopoverOpen(open);
}
// 获取镜像列表
function getFiles() {
if (isLoading) return;
setIsLoading(true);
api
.getImages()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const images = rsp.data.files;
if (!images?.length) {
setFiles([]);
return;
}
setFiles(images);
// 获取已挂载的镜像
api.getMountedImage().then((rsp) => {
if (rsp.code === 0 && rsp.data?.file && images.includes(rsp.data.file)) {
setMountedFile(rsp.data.file);
}
});
})
.finally(() => {
setIsLoading(false);
});
}
// 挂载/取消挂载
function mountFile(file: string) {
if (mountingFile) return;
setMountingFile(file);
client.close();
const filename = mountedFile === file ? '' : file;
api
.mountImage(filename)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMountedFile(filename);
})
.finally(() => {
setMountingFile('');
client.connect();
});
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('images')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{isLoading ? (
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('loading')}</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('empty')}</span>
</div>
) : (
files.map((file) => (
<div
key={file}
className={clsx(
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
{ 'text-blue-500': mountedFile === file }
)}
onClick={() => mountFile(file)}
>
{mountedFile === file ? (
<>
<div className="h-[18px] w-[18px] group-hover:text-red-500">
<PackageIcon size={18} className="block group-hover:hidden" />
<XIcon size={18} className="hidden group-hover:block" />
</div>
</>
) : mountingFile === file ? (
<div className="h-[18px] w-[18px]">
<LoaderCircleIcon className="animate-spin" size={18} />
</div>
) : (
<div className="h-[18px] w-[18px]">
<FileBoxIcon size={18} />
</div>
)}
<span className="break-all">{file.replace(/^.*[\\/]/, '')}</span>
</div>
))
)}
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div
className={clsx(
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
mountedFile ? 'text-blue-500' : 'text-neutral-300'
)}
>
<DiscIcon size={18} />
</div>
</Popover>
);
};