mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
- update axios to 1.7.4
- update menu bar: add tips on how to upload image - optimizing i18n code
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。
|
||||
|
||||
@@ -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
6171
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: '上传',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
133
web/src/pages/desktop/menu-phone/image/images.tsx
Normal file
133
web/src/pages/desktop/menu-phone/image/images.tsx
Normal 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>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
56
web/src/pages/desktop/menu-phone/image/index.tsx
Normal file
56
web/src/pages/desktop/menu-phone/image/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
81
web/src/pages/desktop/menu-phone/image/tips.tsx
Normal file
81
web/src/pages/desktop/menu-phone/image/tips.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
133
web/src/pages/desktop/menu/image/images.tsx
Normal file
133
web/src/pages/desktop/menu/image/images.tsx
Normal 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>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
56
web/src/pages/desktop/menu/image/index.tsx
Normal file
56
web/src/pages/desktop/menu/image/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
81
web/src/pages/desktop/menu/image/tips.tsx
Normal file
81
web/src/pages/desktop/menu/image/tips.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user