feat: i18n
@@ -9,7 +9,9 @@ RUN corepack enable && corepack prepare pnpm@latest-9 --activate && pnpm install
|
|||||||
|
|
||||||
COPY www/api/ ./api
|
COPY www/api/ ./api
|
||||||
COPY www/components/ ./components
|
COPY www/components/ ./components
|
||||||
|
COPY www/config/ ./config
|
||||||
COPY www/hooks/ ./hooks
|
COPY www/hooks/ ./hooks
|
||||||
|
COPY www/i18n/ ./i18n
|
||||||
COPY www/lib/ ./lib
|
COPY www/lib/ ./lib
|
||||||
COPY www/pages/ ./pages
|
COPY www/pages/ ./pages
|
||||||
COPY www/public/ ./public
|
COPY www/public/ ./public
|
||||||
|
282
README.md
@@ -1,57 +1,53 @@
|
|||||||
> 详细博客地址: [https://vaala.cat/2024/01/14/frp-panel-doc/](https://vaala.cat/2024/01/14/frp-panel-doc/)
|
> Detailed blog post: [https://vaala.cat/2024/01/14/frp-panel-doc/](https://vaala.cat/2024/01/14/frp-panel-doc/)
|
||||||
> 使用说明可以看博客,也可以直接滑到最后
|
> You can refer to the blog for instructions, or scroll down to the end.
|
||||||
|
|
||||||
# FRP-Panel
|
# FRP-Panel
|
||||||
|
|
||||||
[English Version README](README_en.md) | [中文文档](README.md)
|
[English Version](README.md) | [中文文档](README_zh.md)
|
||||||
|
|
||||||
我们的目标就是做一个:
|
Our goal is to create a more powerful and comprehensive frp that provides:
|
||||||
|
|
||||||
- 客户端配置可中心化管理
|
- Centralized management of client configurations
|
||||||
- 多服务端配置管理
|
- Management of multiple server configurations
|
||||||
- 可视化配置界面
|
- Visual configuration interface
|
||||||
- 简化运行所需要的配置
|
- Simplified configuration required for running
|
||||||
|
|
||||||
的更强更完善的 frp!
|
|
||||||
|
|
||||||
- demo Video: [demo Video](doc/frp-panel-demo.mp4)
|
- demo Video: [demo Video](doc/frp-panel-demo.mp4)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 项目使用说明
|
## Project Usage Instructions
|
||||||
|
|
||||||
frp-panel 可选 docker 和直接运行模式部署,直接部署请到 release 下载文件:[release](https://github.com/VaalaCat/frp-panel/releases)
|
frp-panel can be deployed in docker or direct run mode. For direct deployment, please download the files from the release: [release](https://github.com/VaalaCat/frp-panel/releases)
|
||||||
|
|
||||||
注意:二进制有两种,一种是仅客户端,一种是全功能可执行文件,客户端版只能执行 client 命令(无需 client 参数)
|
Note: There are two types of binaries, one is for the client only, and the other is a full-featured executable file. The client version will have a "client" identifier in its name.
|
||||||
|
|
||||||
客户端版的名字会带有 client 标识
|
After startup, the default access address is `http://IP:9000`.
|
||||||
|
|
||||||
启动过后默认访问地址为 `http://IP:9000`
|
The first registered user is the administrator by default. User registration is not open by default. If you need it, please add the following parameter to the Master startup command or configuration file: `APP_ENABLE_REGISTER=true`
|
||||||
|
|
||||||
默认第一个注册的用户是管理员。且默认不开放注册多用户,如果需要,请在 Master 启动命令或配置文件中添加参数:`APP_ENABLE_REGISTER=true`
|
After starting, there will be a "default" entry in the server list. If the status shows "Offline" in red, it indicates that your `MASTER_RPC_HOST` environment variable is not configured correctly or the port is not accessible externally. Please carefully check the configuration and redeploy.
|
||||||
|
|
||||||
启动后在服务端列表中会有一个default,如果运行信息为“不在线”且为红色,则说明您的 `MASTER_RPC_HOST` 启动环境变量没有配置正确或端口外部访问不成功,请仔细检查配置重新部署。
|
To test if the port is open, run the following command on the server:
|
||||||
|
|
||||||
测试端口是否开放的方法,在服务器上运行:
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
python3 -m http.server 8080
|
python3 -m http.server 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
然后在浏览器中访问:`http://IP:8080` (端口可以换成任意你想测试的端口),访问成功则为端口开放
|
Then access in the browser: `http://IP:8080` (you can replace Port with any Port you want)
|
||||||
|
|
||||||
程序的默认存储数据路径和程序文件同目录,如需修改请参考下方的配置表格
|
### Docker
|
||||||
|
|
||||||
### docker
|
Note⚠️: The startup commands for client and server may change as the project is updated. Although backward compatibility will be considered during project iterations, it is still difficult to fully adapt. Therefore, the startup commands for client and server should be generated from the master.
|
||||||
|
|
||||||
注意 ⚠️:client 和 server 的启动指令可能会随着项目更新而改变,虽然在项目迭代时会注意前后兼容,但仍难以完全适配,因此 client 和 server 的启动指令以 master 生成为准
|
|
||||||
|
|
||||||
- master
|
- master
|
||||||
|
|
||||||
|
Here's the translated guidance for running the Docker command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 推荐
|
# Recommended
|
||||||
# MASTER_RPC_HOST要改成你服务器的外部IP
|
# Change MASTER_RPC_HOST to the external IP of your server
|
||||||
# APP_GLOBAL_SECRET注意不要泄漏,客户端和服务端的是通过Master生成的
|
# Be careful not to leak APP_GLOBAL_SECRET, it's generated by the Master for both the client and server
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--network=host \
|
--network=host \
|
||||||
--restart=unless-stopped \
|
--restart=unless-stopped \
|
||||||
@@ -59,16 +55,17 @@ docker run -d \
|
|||||||
-e APP_GLOBAL_SECRET=your_secret \
|
-e APP_GLOBAL_SECRET=your_secret \
|
||||||
-e MASTER_RPC_HOST=0.0.0.0 \
|
-e MASTER_RPC_HOST=0.0.0.0 \
|
||||||
vaalacat/frp-panel
|
vaalacat/frp-panel
|
||||||
# 或者
|
|
||||||
# 运行时记得删除命令中的中文
|
# Alternatively
|
||||||
docker run -d -p 9000:9000 \ # API控制台端口
|
# Remember to remove comments when running the command
|
||||||
-p 9001:9001 \ # rpc端口
|
docker run -d -p 9000:9000 \ # API console port
|
||||||
-p 7000:7000 \ # frps 端口
|
-p 9001:9001 \ # RPC port
|
||||||
-p 20000-20050:20000-20050 \ # 给frps预留的端口
|
-p 7000:7000 \ # FRPS port
|
||||||
|
-p 20000-20050:20000-20050 \ # Reserved ports for FRPS
|
||||||
--restart=unless-stopped \
|
--restart=unless-stopped \
|
||||||
-v /opt/frp-panel:/data \ # 数据存储位置
|
-v /opt/frp-panel:/data \ # Data storage location
|
||||||
-e APP_GLOBAL_SECRET=your_secret \ # Master的secret注意不要泄漏,客户端和服务端的是通过Master生成的
|
-e APP_GLOBAL_SECRET=your_secret \ # Be careful not to leak the Master's secret, it's generated by the Master for both the client and server
|
||||||
-e MASTER_RPC_HOST=0.0.0.0 \ # 这里要改成你服务器的外部IP
|
-e MASTER_RPC_HOST=0.0.0.0 \ # Change this to the external IP of your server
|
||||||
vaalacat/frp-panel
|
vaalacat/frp-panel
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,25 +73,25 @@ docker run -d -p 9000:9000 \ # API控制台端口
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--network=host \
|
--network=host \
|
||||||
--restart=unless-stopped \
|
--restart=unless-stopped \
|
||||||
vaalacat/frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
vaalacat/frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
||||||
```
|
```
|
||||||
|
|
||||||
- server
|
- server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--network=host \
|
--network=host \
|
||||||
--restart=unless-stopped \
|
--restart=unless-stopped \
|
||||||
vaalacat/frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
vaalacat/frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
||||||
```
|
```
|
||||||
|
|
||||||
### 直接运行(Linux)
|
### Direct Run (Linux)
|
||||||
|
|
||||||
- master
|
- master
|
||||||
|
|
||||||
注意修改 IP
|
Note: Modify the IP
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_GLOBAL_SECRET=your_secret MASTER_RPC_HOST=0.0.0.0 frp-panel master
|
APP_GLOBAL_SECRET=your_secret MASTER_RPC_HOST=0.0.0.0 frp-panel master
|
||||||
@@ -103,18 +100,20 @@ APP_GLOBAL_SECRET=your_secret MASTER_RPC_HOST=0.0.0.0 frp-panel master
|
|||||||
- client
|
- client
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
||||||
```
|
```
|
||||||
|
|
||||||
- server
|
- server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
||||||
```
|
```
|
||||||
|
|
||||||
### 直接运行(Windows)
|
### Direct Run (Windows)
|
||||||
|
|
||||||
在下载的可执行文件同名文件夹下创建一个 `.env` 文件(注意不要有后缀名),然后输入以下内容保存后运行对应命令,注意,client 和 server 的对应参数需要在 web 页面复制
|
In the same folder as the downloaded executable, create a `.env` file (note that there should be no file extension), then enter the following content and save it before running the corresponding command. Note that the corresponding parameters for client and server need to be copied from the web page.
|
||||||
|
|
||||||
|
- master: `frp-panel-amd64.exe master`
|
||||||
|
|
||||||
```
|
```
|
||||||
APP_GLOBAL_SECRET=your_secret
|
APP_GLOBAL_SECRET=your_secret
|
||||||
@@ -122,34 +121,32 @@ MASTER_RPC_HOST=IP
|
|||||||
DB_DSN=data.db
|
DB_DSN=data.db
|
||||||
```
|
```
|
||||||
|
|
||||||
- master: `frp-panel-amd64.exe master`
|
For client and server, use the parameters copied from the master WebUI.
|
||||||
|
|
||||||
client 和 server 要使用在 master WebUI 复制的参数
|
- client: `frp-panel-amd64.exe client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI`
|
||||||
|
|
||||||
- client: `frp-panel-amd64.exe client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数`
|
- server: `frp-panel-amd64.exe server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI`
|
||||||
|
|
||||||
- server: `frp-panel-amd64.exe server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数`
|
### Tunnel Advanced Mode Configuration
|
||||||
|
|
||||||
### 隧道高级模式配置
|
This panel is fully compatible with frp's original `json` format configuration. You only need to paste the configuration file content into the advanced mode editor for the server/client, and then update it. For detailed usage, please refer to: [frp documentation](https://gofrp.org/docs/features/common/configure/)
|
||||||
|
|
||||||
本面板完全兼容 frp 原本的`json`格式配置,仅需要将配置文件内容粘贴到服务端/客户端高级模式编辑框内,更新即可,详细的使用参考:[frp 文档](https://gofrp.org/zh-cn/docs/features/common/configure/)
|
### Program Startup Configuration File
|
||||||
|
|
||||||
### 程序启动配置文件
|
The program will read the contents of the following files in order as the configuration file: `.env`, `/etc/frpp/.env`
|
||||||
|
|
||||||
程序会按顺序读取以下文件内容作为配置文件:`.env`,`/etc/frpp/.env`
|
### Service Management
|
||||||
|
|
||||||
### 服务管理
|
If you are using the installation script provided by the panel, systemd is used for Linux control, and frpp.exe is used for Windows control.
|
||||||
|
|
||||||
如果您使用的是面板自带的安装脚本,对于 Linux 使用 systemd 控制,对于 Windows 使用 本程序 控制
|
Examples of using Linux after installation:
|
||||||
|
|
||||||
Linux 安装后使用示例:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl stop frpp
|
systemctl stop frpp
|
||||||
systemctl start frpp
|
systemctl start frpp
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows 安装后使用示例:
|
Examples of using Windows after installation:
|
||||||
|
|
||||||
```
|
```
|
||||||
C:/frpp/frpp.exe start
|
C:/frpp/frpp.exe start
|
||||||
@@ -157,93 +154,62 @@ C:/frpp/frpp.exe stop
|
|||||||
C:/frpp/frpp.exe uninstall
|
C:/frpp/frpp.exe uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置说明
|
## Project Development Guide
|
||||||
|
|
||||||
| 类型 | 环境变量名 | 默认值 | 描述 |
|
### Platform Architecture Design
|
||||||
|--------|-------------------------------------|--------------------|----------------------------------------------------------------|
|
|
||||||
| string | `APP_SECRET` | - | 应用密钥,用于客户端和服务器的和Master的通信加密 |
|
|
||||||
| string | `APP_GLOBAL_SECRET` | `frp-panel` | 全局密钥,用于管理生成密钥,需妥善保管 |
|
|
||||||
| int | `APP_COOKIE_AGE` | `86400` | Cookie 的有效期(秒),默认值为 1 天 |
|
|
||||||
| string | `APP_COOKIE_NAME` | `frp-panel-cookie` | Cookie 名称 |
|
|
||||||
| string | `APP_COOKIE_PATH` | `/` | Cookie 路径 |
|
|
||||||
| string | `APP_COOKIE_DOMAIN` | - | Cookie 域 |
|
|
||||||
| bool | `APP_COOKIE_SECURE` | `false` | Cookie 是否安全 |
|
|
||||||
| bool | `APP_COOKIE_HTTP_ONLY` | `true` | Cookie 是否仅限 HTTP |
|
|
||||||
| bool | `APP_ENABLE_REGISTER` | `false` | 是否启用注册,仅允许第一个管理员注册 |
|
|
||||||
| int | `MASTER_API_PORT` | `9000` | 主节点 API 端口 |
|
|
||||||
| string | `MASTER_API_HOST` | - | 主节点域名,可以在反向代理和CDN后 |
|
|
||||||
| string | `MASTER_API_SCHEME` | `http` | 主节点 API 协议(注意,这里不影响主机行为,设置为https只是为了方便复制客户端启动命令,HTTPS需要自行反向代理)|
|
|
||||||
| int | `MASTER_CACHE_SIZE` | `10` | 缓存大小(MB) |
|
|
||||||
| string | `MASTER_RPC_HOST` | `127.0.0.1` | Master节点公共 IP 或域名 |
|
|
||||||
| int | `MASTER_RPC_PORT` | `9001` | Master节点 RPC 端口 |
|
|
||||||
| bool | `MASTER_COMPATIBLE_MODE` | `false` | 兼容模式,用于官方 frp 客户端 |
|
|
||||||
| string | `MASTER_INTERNAL_FRP_SERVER_HOST` | - | Master内置 frps 服务器主机,用于客户端连接 |
|
|
||||||
| int | `MASTER_INTERNAL_FRP_SERVER_PORT` | `9002` | Master内置 frps 服务器端口,用于客户端连接 |
|
|
||||||
| string | `MASTER_INTERNAL_FRP_AUTH_SERVER_HOST` | `127.0.0.1` | Master内置 frps 认证服务器主机 |
|
|
||||||
| int | `MASTER_INTERNAL_FRP_AUTH_SERVER_PORT` | `8999` | Master内置 frps 认证服务器端口 |
|
|
||||||
| string | `MASTER_INTERNAL_FRP_AUTH_SERVER_PATH` | `/auth` | Master内置 frps 认证服务器路径 |
|
|
||||||
| int | `SERVER_API_PORT` | `8999` | 服务器 API 端口 |
|
|
||||||
| string | `DB_TYPE` | `sqlite3` | 数据库类型,如 mysql postgres 或 sqlite3 等 |
|
|
||||||
| string | `DB_DSN` | `data.db` | 数据库 DSN,默认使用sqlite3,数据默认存储在可执行文件同目录下,对于 sqlite 是路径,其他数据库为 DSN,参见 [MySQL DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) |
|
|
||||||
| string | `CLIENT_ID` | - | 客户端 ID |
|
|
||||||
| string | `CLIENT_SECRET` | - | 客户端密钥 |
|
|
||||||
|
|
||||||
## 项目开发指南
|
After choosing the tech stack, the next step is to design the program architecture. As mentioned in the background, frp itself has frpc and frps (client and server), these two roles are indispensable. Then we need to add something new to manage them, so frp-panel introduces a new master role. The master will be responsible for managing various frpc and frps, as well as centrally storing configuration files and connection information.
|
||||||
|
|
||||||
### 平台架构设计
|
Next, we have frpc and frps. The original version requires writing configuration files on both sides. Since the original version already supports this, we don't need to follow the original approach. We will directly not support configuration files, and all configurations must be obtained from the master.
|
||||||
|
|
||||||
技术栈选好了,下一步就是要设计程序的架构。在刚刚背景里说的那样,frp 本身有 frpc 和 frps(客户端和服务端),这两个角色肯定是必不可少了。然后我们还要新增一个东西去管理它们,所以 frp-panel 新增了一个 master 角色。master 会负责管理各种 frpc 和 frps,中心化的存储配置文件和连接信息。
|
In addition, we also need to consider the compatibility with the original version. The client/server of frp-panel must be able to connect to the official frpc/frps service. In this way, both configuration file and non-configuration file modes can work perfectly.
|
||||||
|
Overall, the architecture is quite simple.
|
||||||
然后是 frpc 和 frps。原版是需要在两边分别写配置文件的。那么既然原版已经支持了,就不用在走原版的路子,我们直接不支持配置文件,所有的配置都必须从 master 获取。
|
|
||||||
|
|
||||||
其次还要考虑到与原版的兼容问题,frp-panel 的客户端/服务端都必须要能连上官方 frpc/frps 服务。这样的话就可以做到配置文件/不要配置文件都能完美工作了。
|
|
||||||
总的说来架构还是很简单的。
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 开发
|
### Development
|
||||||
|
|
||||||
项目包含三个角色
|
The project includes three roles:
|
||||||
|
|
||||||
1. Master: 控制节点,接受来自前端的请求并负责管理 Client 和 Server
|
1. Master: The control node, accepts requests from the frontend and is responsible for managing Client and Server.
|
||||||
2. Server: 服务端,受控制节点控制,负责对客户端提供服务,包含 frps 和 rpc(用于连接 Master)服务
|
2. Server: The server side, controlled by the control node, responsible for providing services to clients, including frps and rpc (for connecting to the Master) services.
|
||||||
3. Client: 客户端,受控制节点控制,包含 frpc 和 rpc(用于连接 Master)服务
|
3. Client: The client side, controlled by the control node, including frpc and rpc (for connecting to the Master) services.
|
||||||
|
|
||||||
接下来给出一个项目中各个包的功能
|
Next, we will provide the functionality of each package in the project:
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
|-- biz # 主要业务逻辑
|
|-- biz # Main business logic
|
||||||
| |-- client # 客户端逻辑(这里指的是frp-panel的客户端)
|
| |-- client # Client logic (here referring to the frp-panel client)
|
||||||
| |-- master # frp-panel 控制平面,负责处理前端请求,并且使用rpc管理frp-panel的server和client
|
| |-- master # frp-panel control plane, responsible for handling frontend requests, and using rpc to manage frp-panel's server and client
|
||||||
| | |-- auth # 认证模块,包含用户认证和客户端认证
|
| | |-- auth # Authentication module, including user authentication and client authentication
|
||||||
| | |-- client # 客户端模块,包含前端管理客户端的各种API
|
| | |-- client # Client module, including various APIs for the frontend to manage clients
|
||||||
| | |-- server # 服务端模块,包含前端管理服务端的各种API
|
| | |-- server # Server module, including various APIs for the frontend to manage servers
|
||||||
| | `-- user # 用户模块,包含用户管理、用户信息获取等
|
| | `-- user # User module, including user management, user information retrieval, etc.
|
||||||
| `-- server # 服务端逻辑(这里指的是frp-panel的服务端)
|
| `-- server # Server logic
|
||||||
|-- cache # 缓存,用于存储frps的认证token
|
|-- cache # Cache, used to store frps authentication tokens
|
||||||
|-- cmd # 命令行入口,main函数的所在地,负责按需启动各个模块
|
|-- cmd # Command line entry, where the main function is located, responsible for starting various modules as needed
|
||||||
|-- common
|
|-- common
|
||||||
|-- conf
|
|-- conf
|
||||||
|-- dao # data access object,任何和数据库相关的操作会调用这个库
|
|-- dao # Data access object, any operations related to the database will call this library
|
||||||
|-- doc # 文档
|
|-- doc # Documentation
|
||||||
|-- idl # idl定义
|
|-- idl # IDL definitions
|
||||||
|-- middleware # api的中间件,包含JWT和context相关,用于处理api请求,鉴权通过后会把用户信息注入到context,可以通过common包获取
|
|-- middleware # API middleware, including JWT and context-related, used to process API requests. After authentication passes, user information will be injected into the context and can be obtained through the common package.
|
||||||
|-- models # 数据库模型,用于定义数据库表。同时包含实体定义
|
|-- models # Database models, used to define database tables. Also includes entity definitions.
|
||||||
|-- pb # protobuf生成的pb文件
|
|-- pb # Generated protobuf pb files
|
||||||
|-- rpc # 各种rpc的所在地,包含Client/Server调用Master的逻辑,也包含Master使用Stream调用Client和Server的逻辑
|
|-- rpc # Location of various rpcs, including the logic for Client/Server to call Master, as well as the logic for Master to use Stream to call Client and Server
|
||||||
|-- services # 各种需要在内存中持久运行的模块,这个包可以管理各个服务的运行/停止
|
|-- services # Various modules that need to run persistently in memory, this package can manage the running/stopping of various services
|
||||||
| |-- api # api服务,运行需要外部传入一个ginRouter
|
| |-- api # API service, requires an external ginRouter to run
|
||||||
| |-- client # frp的客户端,即frpc,可以控制frpc的各种配置/开始与停止
|
| |-- client # frp client, i.e., frpc, can control various configurations/start and stop of frpc
|
||||||
| |-- master # master服务,包含rpc的服务端定义,接收到rpc请求后会调用biz包处理逻辑
|
| |-- master # Master service, including the rpc server definition, after receiving an rpc request, it will call the biz package to handle the logic
|
||||||
| |-- rpcclient # 有状态的rpc客户端,因为rpc的client都没有公网ip,因此在rpc client启动时会调用master的stream长连接rpc,建立连接后Master和Client通过这个包通信
|
| |-- rpcclient # Stateful rpc client, because the rpc clients don't have public IP addresses, the rpcclient will call the master's stream long-connection rpc when starting, and after the connection is established, the Master and Client communicate through this package
|
||||||
| `-- server # frp的服务端,即frps,可以控制frps的各种配置/开始与停止
|
| `-- server # frp server, i.e., frps, can control various configurations/start and stop of frps
|
||||||
|-- tunnel # tunnel模块,用于管理tunnel,也就是管理frpc和frps服务
|
|-- tunnel # Tunnel module, used to manage tunnels, i.e., manage frpc and frps services
|
||||||
|-- utils
|
|-- utils
|
||||||
|-- watcher # 定时运行的任务,比如每30秒更新一次配置文件
|
|-- watcher # Scheduled tasks, e.g., updating configuration files every 30 seconds
|
||||||
`-- www
|
`-- www
|
||||||
|-- api
|
|-- api
|
||||||
|-- components # 这里面有一个apitest组件用于测试
|
|-- components # There is an apitest component here for testing
|
||||||
| `-- ui
|
| `-- ui
|
||||||
|-- lib
|
|-- lib
|
||||||
| `-- pb
|
| `-- pb
|
||||||
@@ -252,38 +218,54 @@ C:/frpp/frpp.exe uninstall
|
|||||||
|-- store
|
|-- store
|
||||||
|-- styles
|
|-- styles
|
||||||
`-- types
|
`-- types
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 调试启动方式:
|
### Debugging and Startup Methods:
|
||||||
|
|
||||||
- master: `go run cmd/*.go master`
|
- master: `go run cmd/*.go master`
|
||||||
> client 和 server 的具体参数请复制 master webui 中的内容
|
> For client and server, please copy the content from the master webui
|
||||||
- client: `go run cmd/*.go client -i <clientID> -s <clientSecret>`
|
- client: `go run cmd/*.go client -i <clientID> -s <clientSecret>`
|
||||||
- server: `go run cmd/*.go server -i <serverID> -s <serverSecret>`
|
- server: `go run cmd/*.go server -i <serverID> -s <serverSecret>`
|
||||||
|
|
||||||
项目配置文件会默认读取当前文件夹下的.env 文件,项目内置了样例配置文件,可以按照自己的需求进行修改
|
The project configuration file will read the .env file in the current folder by default. The project includes a sample configuration file, which can be modified according to your needs.
|
||||||
|
|
||||||
详细架构调用图
|
Detailed architecture call diagram:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 本体配置说明
|
### Core Configuration Explanation
|
||||||
|
|
||||||
[settings.go](conf/settings.go)
|
[settings.go](conf/settings.go)
|
||||||
这里有详细的配置参数解释,需要进一步修改配置请参考该文件
|
This file contains detailed explanations of the configuration parameters. Please refer to this file if you need to further modify the configuration.
|
||||||
|
|
||||||
### 一些图片
|
## Screenshots
|
||||||
|
|
||||||

|
### Index Page
|
||||||

|

|
||||||

|
|
||||||

|
### Server List
|
||||||

|

|
||||||

|
|
||||||

|
### Server Edit
|
||||||

|

|
||||||

|
|
||||||

|
### Server Edit Advanced
|
||||||

|

|
||||||

|
|
||||||
|
### Client List
|
||||||
|

|
||||||
|
|
||||||
|
### Client Edit
|
||||||
|

|
||||||
|
|
||||||
|
### Client Edit Advanced
|
||||||
|

|
||||||
|
|
||||||
|
### Client Stats
|
||||||
|

|
||||||
|
|
||||||
|
### Realtime Log
|
||||||
|

|
||||||
|
|
||||||
|
### Remote Console
|
||||||
|

|
||||||
|
255
README_en.md
@@ -1,255 +0,0 @@
|
|||||||
> Detailed blog post: [https://vaala.cat/2024/01/14/frp-panel-doc/](https://vaala.cat/2024/01/14/frp-panel-doc/)
|
|
||||||
> You can refer to the blog for instructions, or scroll down to the end.
|
|
||||||
|
|
||||||
# FRP-Panel
|
|
||||||
|
|
||||||
[English Version](README_en.md) | [中文文档](README.md)
|
|
||||||
|
|
||||||
Our goal is to create a more powerful and comprehensive frp that provides:
|
|
||||||
|
|
||||||
- Centralized management of client configurations
|
|
||||||
- Management of multiple server configurations
|
|
||||||
- Visual configuration interface
|
|
||||||
- Simplified configuration required for running
|
|
||||||
|
|
||||||
- demo Video: [demo Video](doc/frp-panel-demo.mp4)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Project Usage Instructions
|
|
||||||
|
|
||||||
frp-panel can be deployed in docker or direct run mode. For direct deployment, please download the files from the release: [release](https://github.com/VaalaCat/frp-panel/releases)
|
|
||||||
|
|
||||||
Note: There are two types of binaries, one is for the client only, and the other is a full-featured executable file. The client version will have a "client" identifier in its name.
|
|
||||||
|
|
||||||
After startup, the default access address is `http://IP:9000`.
|
|
||||||
|
|
||||||
The first registered user is the administrator by default. User registration is not open by default. If you need it, please add the following parameter to the Master startup command or configuration file: `APP_ENABLE_REGISTER=true`
|
|
||||||
|
|
||||||
After starting, there will be a "default" entry in the server list. If the status shows "Offline" in red, it red, it indicates that your `MASTER_RPC_HOST` environment variable is not confi not confi not configured correctly or the port is not accessible externally. Please carefully check the configuration and redeploy.
|
|
||||||
|
|
||||||
To test if the port if the port if the port is open, run the following command on the server:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
python3 -m http.server 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
Then access in the browser: `http://IP:8080` (you can replace Port with any Port you want
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
Note⚠️: The startup commands for client and server may change as the project is updated. Although backward compatibility will be considered during project iterations, it is still difficult to fully adapt. Therefore, the startup commands for client and server should be generated from the master.
|
|
||||||
|
|
||||||
- master
|
|
||||||
|
|
||||||
Here's the translated guidance for running the Docker command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Recombash
|
|
||||||
# Recommended
|
|
||||||
# Change MASTER_RPC_HOST to the external IP of your of your server
|
|
||||||
# Be careful not careful not to leak APP_GLOBAL_SECRET, it's generated by the Master for both the client and server
|
|
||||||
docker run -d \
|
|
||||||
--network=host \
|
|
||||||
--restart=unless-stopped \
|
|
||||||
-v /opt/frp-panel:/data \
|
|
||||||
-e APP_GLOBAL_SECRET=your_secret \
|
|
||||||
-e MASTER_RPC_HOST=0.0.0.0 \
|
|
||||||
vaalacat/frp-panel
|
|
||||||
|
|
||||||
# Alternatively
|
|
||||||
# Remember to remove comments when running the command
|
|
||||||
docker run -d -p 9000:9000 \ # API console port
|
|
||||||
-p 9001:9001 \ # RPC port
|
|
||||||
-p 7000:7000 \ # FRPS port
|
|
||||||
-p 20000-20050:20000-20050 \ # Reserved ports for FRPS
|
|
||||||
--restart=unless-stopped \
|
|
||||||
-v /opt/frp-panel:/data \ # Data storage location
|
|
||||||
-e APP_GLOBAL_SECRET=your_secret \ # Be careful not to leak the Master's secret, it's generated by the Master for both the client and server
|
|
||||||
-e MASTER_RPC_HOST=0.0.0.0 \ # Change this to the external IP of your server
|
|
||||||
vaalacat/frp-panel
|
|
||||||
```
|
|
||||||
|
|
||||||
- client
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--network=host \
|
|
||||||
--restart=unless-stopped \
|
|
||||||
vaalacat/frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
|
||||||
```
|
|
||||||
|
|
||||||
- server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--network=host \
|
|
||||||
--restart=unless-stopped \
|
|
||||||
vaalacat/frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
|
||||||
```
|
|
||||||
|
|
||||||
### Direct Run (Linux)
|
|
||||||
|
|
||||||
- master
|
|
||||||
|
|
||||||
Note: Modify the IP
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_GLOBAL_SECRET=your_secret MASTER_RPC_HOST=0.0.0.0 frp-panel master
|
|
||||||
```
|
|
||||||
|
|
||||||
- client
|
|
||||||
|
|
||||||
```bash
|
|
||||||
frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
|
||||||
```
|
|
||||||
|
|
||||||
- server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI
|
|
||||||
```
|
|
||||||
|
|
||||||
### Direct Run (Windows)
|
|
||||||
|
|
||||||
In the same folder as the downloaded executable, create a `.env` file (note that there should be no file extension), then enter the following content and save it before running the corresponding command. Note that the corresponding parameters for client and server need to be copied from the web page.
|
|
||||||
|
|
||||||
- master: `frp-panel-amd64.exe master`
|
|
||||||
|
|
||||||
```
|
|
||||||
APP_GLOBAL_SECRET=your_secret
|
|
||||||
MASTER_RPC_HOST=IP
|
|
||||||
DB_DSN=data.db
|
|
||||||
```
|
|
||||||
|
|
||||||
For client and server, use the parameters copied from the master WebUI.
|
|
||||||
|
|
||||||
- client: `frp-panel-amd64.exe client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI`
|
|
||||||
|
|
||||||
- server: `frp-panel-amd64.exe server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # Copy the parameters from the master WebUI`
|
|
||||||
|
|
||||||
### Tunnel Advanced Mode Configuration
|
|
||||||
|
|
||||||
This panel is fully compatible with frp's original `json` format configuration. You only need to paste the configuration file content into the advanced mode editor for the server/client, and then update it. For detailed usage, please refer to: [frp documentation](https://gofrp.org/docs/features/common/configure/)
|
|
||||||
|
|
||||||
### Program Startup Configuration File
|
|
||||||
|
|
||||||
The program will read the contents of the following files in order as the configuration file: `.env`, `/etc/frpp/.env`
|
|
||||||
|
|
||||||
### Service Management
|
|
||||||
|
|
||||||
If you are using the installation script provided by the panel, systemd is used for Linux control, and frpp.exe is used for Windows control.
|
|
||||||
|
|
||||||
Examples of using Linux after installation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl stop frpp
|
|
||||||
systemctl start frpp
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples of using Windows after installation:
|
|
||||||
|
|
||||||
```
|
|
||||||
C:/frpp/frpp.exe start
|
|
||||||
C:/frpp/frpp.exe stop
|
|
||||||
C:/frpp/frpp.exe uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Development Guide
|
|
||||||
|
|
||||||
### Platform Architecture Design
|
|
||||||
|
|
||||||
After choosing the tech stack, the next step is to design the architecture of the program. As mentioned in the background, frp itself has frpc and frps (client and server), these two roles are indispensable. Then we need to add something new to manage them, so frp-panel introduces a new master role. The master will be responsible for managing various frpc and frps, as well as centrally storing configuration files and connection information.
|
|
||||||
|
|
||||||
Next, we have frpc and frps. The original version requires writing configuration files on both sides. Since the original version already supports this, we don't need to follow the original approach. We will directly not support configuration files, and all configurations must be obtained from the master.
|
|
||||||
|
|
||||||
In addition, we also need to consider the compatibility with the original version. The client/server of frp-panel must be able to connect to the official frpc/frps service. In this way, both configuration file and non-configuration file modes can work perfectly.
|
|
||||||
Overall, the architecture is quite simple.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
The project includes three roles:
|
|
||||||
|
|
||||||
1. Master: The control node, accepts requests from the frontend and is responsible for managing Client and Server.
|
|
||||||
2. Server: The server side, controlled by the control node, responsible for providing services to clients, including frps and rpc (for connecting to the Master) services.
|
|
||||||
3. Client: The client side, controlled by the control node, including frpc and rpc (for connecting to the Master) services.
|
|
||||||
|
|
||||||
Next, we will provide the functionality of each package in the project:
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
|-- biz # Main business logic
|
|
||||||
| |-- client # Client logic (here referring to the frp-panel client)
|
|
||||||
| |-- master # frp-panel control plane, responsible for handling frontend requests, and using rpc to manage frp-panel's server and client
|
|
||||||
| | |-- auth # Authentication module, including user authentication and client authentication
|
|
||||||
| | |-- client # Client module, including various APIs for the frontend to manage clients
|
|
||||||
| | |-- server # Server module, including various APIs for the frontend to manage servers
|
|
||||||
| | `-- user # User module, including user management, user information retrieval, etc.
|
|
||||||
| `-- server # Server logic
|
|
||||||
|-- cache # Cache, used to store frps authentication tokens
|
|
||||||
|-- cmd # Command line entry, where the main function is located, responsible for starting various modules as needed
|
|
||||||
|-- common
|
|
||||||
|-- conf
|
|
||||||
|-- dao # Data access object, any operations related to the database will call this library
|
|
||||||
|-- doc # Documentation
|
|
||||||
|-- idl # IDL definitions
|
|
||||||
|-- middleware # API middleware, including JWT and context-related, used to process API requests. After authentication passes, user information will be injected into the context and can be obtained through the common package.
|
|
||||||
|-- models # Database models, used to define database tables. Also includes entity definitions.
|
|
||||||
|-- pb # Generated protobuf pb files
|
|
||||||
|-- rpc # Location of various rpcs, including the logic for Client/Server to call Master, as well as the logic for Master to use Stream to call Client and Server
|
|
||||||
|-- services # Various modules that need to run persistently in memory, this package can manage the running/stopping of various services
|
|
||||||
| |-- api # API service, requires an external ginRouter to run
|
|
||||||
| |-- client # frp client, i.e., frpc, can control various configurations/start and stop of frpc
|
|
||||||
| |-- master # Master service, including the rpc server definition, after receiving an rpc request, it will call the biz package to handle the logic
|
|
||||||
| |-- rpcclient # Stateful rpc client, because the rpc clients don't have public IP addresses, the rpcclient will call the master's stream long-connection rpc when starting, and after the connection is established, the Master and Client communicate through this package
|
|
||||||
| `-- server # frp server, i.e., frps, can control various configurations/start and stop of frps
|
|
||||||
|-- tunnel # Tunnel module, used to manage tunnels, i.e., manage frpc and frps services
|
|
||||||
|-- utils
|
|
||||||
|-- watcher # Scheduled tasks, e.g., updating configuration files every 30 seconds
|
|
||||||
`-- www
|
|
||||||
|-- api
|
|
||||||
|-- components # There is an apitest component here for testing
|
|
||||||
| `-- ui
|
|
||||||
|-- lib
|
|
||||||
| `-- pb
|
|
||||||
|-- pages
|
|
||||||
|-- public
|
|
||||||
|-- store
|
|
||||||
|-- styles
|
|
||||||
`-- types
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debugging and Startup Methods:
|
|
||||||
|
|
||||||
- master: `go run cmd/*.go master`
|
|
||||||
> For client and server, please copy the content from the master webui
|
|
||||||
- client: `go run cmd/*.go client -i <clientID> -s <clientSecret>`
|
|
||||||
- server: `go run cmd/*.go server -i <serverID> -s <serverSecret>`
|
|
||||||
|
|
||||||
The project configuration file will read the .env file in the current folder by default. The project includes a sample configuration file, which can be modified according to your needs.
|
|
||||||
|
|
||||||
Detailed architecture call diagram:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Core Configuration Explanation
|
|
||||||
|
|
||||||
[settings.go](conf/settings.go)
|
|
||||||
This file contains detailed explanations of the configuration parameters. Please refer to this file if you need to further modify the configuration.
|
|
||||||
|
|
||||||
### Some Images
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
306
README_zh.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
> 详细博客地址: [https://vaala.cat/2024/01/14/frp-panel-doc/](https://vaala.cat/2024/01/14/frp-panel-doc/)
|
||||||
|
> 使用说明可以看博客,也可以直接滑到最后
|
||||||
|
|
||||||
|
# FRP-Panel
|
||||||
|
|
||||||
|
[English Version](README.md) | [中文文档](README_zh.md)
|
||||||
|
|
||||||
|
我们的目标就是做一个:
|
||||||
|
|
||||||
|
- 客户端配置可中心化管理
|
||||||
|
- 多服务端配置管理
|
||||||
|
- 可视化配置界面
|
||||||
|
- 简化运行所需要的配置
|
||||||
|
|
||||||
|
的更强更完善的 frp!
|
||||||
|
|
||||||
|
- demo Video: [demo Video](doc/frp-panel-demo.mp4)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 项目使用说明
|
||||||
|
|
||||||
|
frp-panel 可选 docker 和直接运行模式部署,直接部署请到 release 下载文件:[release](https://github.com/VaalaCat/frp-panel/releases)
|
||||||
|
|
||||||
|
注意:二进制有两种,一种是仅客户端,一种是全功能可执行文件,客户端版只能执行 client 命令(无需 client 参数)
|
||||||
|
|
||||||
|
客户端版的名字会带有 client 标识
|
||||||
|
|
||||||
|
启动过后默认访问地址为 `http://IP:9000`
|
||||||
|
|
||||||
|
默认第一个注册的用户是管理员。且默认不开放注册多用户,如果需要,请在 Master 启动命令或配置文件中添加参数:`APP_ENABLE_REGISTER=true`
|
||||||
|
|
||||||
|
启动后在服务端列表中会有一个default,如果运行信息为“不在线”且为红色,则说明您的 `MASTER_RPC_HOST` 启动环境变量没有配置正确或端口外部访问不成功,请仔细检查配置重新部署。
|
||||||
|
|
||||||
|
测试端口是否开放的方法,在服务器上运行:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
python3 -m http.server 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在浏览器中访问:`http://IP:8080` (端口可以换成任意你想测试的端口),访问成功则为端口开放
|
||||||
|
|
||||||
|
程序的默认存储数据路径和程序文件同目录,如需修改请参考下方的配置表格
|
||||||
|
|
||||||
|
### docker
|
||||||
|
|
||||||
|
注意 ⚠️:client 和 server 的启动指令可能会随着项目更新而改变,虽然在项目迭代时会注意前后兼容,但仍难以完全适配,因此 client 和 server 的启动指令以 master 生成为准
|
||||||
|
|
||||||
|
- master
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推荐
|
||||||
|
# MASTER_RPC_HOST要改成你服务器的外部IP
|
||||||
|
# APP_GLOBAL_SECRET注意不要泄漏,客户端和服务端的是通过Master生成的
|
||||||
|
docker run -d \
|
||||||
|
--network=host \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
-v /opt/frp-panel:/data \
|
||||||
|
-e APP_GLOBAL_SECRET=your_secret \
|
||||||
|
-e MASTER_RPC_HOST=0.0.0.0 \
|
||||||
|
vaalacat/frp-panel
|
||||||
|
# 或者
|
||||||
|
# 运行时记得删除命令中的中文
|
||||||
|
docker run -d -p 9000:9000 \ # API控制台端口
|
||||||
|
-p 9001:9001 \ # rpc端口
|
||||||
|
-p 7000:7000 \ # frps 端口
|
||||||
|
-p 20000-20050:20000-20050 \ # 给frps预留的端口
|
||||||
|
--restart=unless-stopped \
|
||||||
|
-v /opt/frp-panel:/data \ # 数据存储位置
|
||||||
|
-e APP_GLOBAL_SECRET=your_secret \ # Master的secret注意不要泄漏,客户端和服务端的是通过Master生成的
|
||||||
|
-e MASTER_RPC_HOST=0.0.0.0 \ # 这里要改成你服务器的外部IP
|
||||||
|
vaalacat/frp-panel
|
||||||
|
```
|
||||||
|
|
||||||
|
- client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--network=host \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
vaalacat/frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
||||||
|
```
|
||||||
|
|
||||||
|
- server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--network=host \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
vaalacat/frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 直接运行(Linux)
|
||||||
|
|
||||||
|
- master
|
||||||
|
|
||||||
|
注意修改 IP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_GLOBAL_SECRET=your_secret MASTER_RPC_HOST=0.0.0.0 frp-panel master
|
||||||
|
```
|
||||||
|
|
||||||
|
- client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
frp-panel client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
||||||
|
```
|
||||||
|
|
||||||
|
- server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
frp-panel server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 直接运行(Windows)
|
||||||
|
|
||||||
|
在下载的可执行文件同名文件夹下创建一个 `.env` 文件(注意不要有后缀名),然后输入以下内容保存后运行对应命令,注意,client 和 server 的对应参数需要在 web 页面复制
|
||||||
|
|
||||||
|
```
|
||||||
|
APP_GLOBAL_SECRET=your_secret
|
||||||
|
MASTER_RPC_HOST=IP
|
||||||
|
DB_DSN=data.db
|
||||||
|
```
|
||||||
|
|
||||||
|
- master: `frp-panel-amd64.exe master`
|
||||||
|
|
||||||
|
client 和 server 要使用在 master WebUI 复制的参数
|
||||||
|
|
||||||
|
- client: `frp-panel-amd64.exe client -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数`
|
||||||
|
|
||||||
|
- server: `frp-panel-amd64.exe server -s xxxx -i xxxx -a xxxx -r 127.0.0.1 -c 9001 -p 9000 -e http # 在master WebUI复制的参数`
|
||||||
|
|
||||||
|
### 隧道高级模式配置
|
||||||
|
|
||||||
|
本面板完全兼容 frp 原本的`json`格式配置,仅需要将配置文件内容粘贴到服务端/客户端高级模式编辑框内,更新即可,详细的使用参考:[frp 文档](https://gofrp.org/zh-cn/docs/features/common/configure/)
|
||||||
|
|
||||||
|
### 程序启动配置文件
|
||||||
|
|
||||||
|
程序会按顺序读取以下文件内容作为配置文件:`.env`,`/etc/frpp/.env`
|
||||||
|
|
||||||
|
### 服务管理
|
||||||
|
|
||||||
|
如果您使用的是面板自带的安装脚本,对于 Linux 使用 systemd 控制,对于 Windows 使用 本程序 控制
|
||||||
|
|
||||||
|
Linux 安装后使用示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl stop frpp
|
||||||
|
systemctl start frpp
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 安装后使用示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
C:/frpp/frpp.exe stop
|
||||||
|
C:/frpp/frpp.exe start
|
||||||
|
C:/frpp/frpp.exe uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置说明
|
||||||
|
|
||||||
|
| 类型 | 环境变量名 | 默认值 | 描述 |
|
||||||
|
|--------|-------------------------------------|--------------------|----------------------------------------------------------------|
|
||||||
|
| string | `APP_SECRET` | - | 应用密钥,用于客户端和服务器的和Master的通信加密 |
|
||||||
|
| string | `APP_GLOBAL_SECRET` | `frp-panel` | 全局密钥,用于管理生成密钥,需妥善保管 |
|
||||||
|
| int | `APP_COOKIE_AGE` | `86400` | Cookie 的有效期(秒),默认值为 1 天 |
|
||||||
|
| string | `APP_COOKIE_NAME` | `frp-panel-cookie` | Cookie 名称 |
|
||||||
|
| string | `APP_COOKIE_PATH` | `/` | Cookie 路径 |
|
||||||
|
| string | `APP_COOKIE_DOMAIN` | - | Cookie 域 |
|
||||||
|
| bool | `APP_COOKIE_SECURE` | `false` | Cookie 是否安全 |
|
||||||
|
| bool | `APP_COOKIE_HTTP_ONLY` | `true` | Cookie 是否仅限 HTTP |
|
||||||
|
| bool | `APP_ENABLE_REGISTER` | `false` | 是否启用注册,仅允许第一个管理员注册 |
|
||||||
|
| int | `MASTER_API_PORT` | `9000` | 主节点 API 端口 |
|
||||||
|
| string | `MASTER_API_HOST` | - | 主节点域名,可以在反向代理和CDN后 |
|
||||||
|
| string | `MASTER_API_SCHEME` | `http` | 主节点 API 协议(注意,这里不影响主机行为,设置为https只是为了方便复制客户端启动命令,HTTPS需要自行反向代理)|
|
||||||
|
| int | `MASTER_CACHE_SIZE` | `10` | 缓存大小(MB) |
|
||||||
|
| string | `MASTER_RPC_HOST` | `127.0.0.1` | Master节点公共 IP 或域名 |
|
||||||
|
| int | `MASTER_RPC_PORT` | `9001` | Master节点 RPC 端口 |
|
||||||
|
| bool | `MASTER_COMPATIBLE_MODE` | `false` | 兼容模式,用于官方 frp 客户端 |
|
||||||
|
| string | `MASTER_INTERNAL_FRP_SERVER_HOST` | - | Master内置 frps 服务器主机,用于客户端连接 |
|
||||||
|
| int | `MASTER_INTERNAL_FRP_SERVER_PORT` | `9002` | Master内置 frps 服务器端口,用于客户端连接 |
|
||||||
|
| string | `MASTER_INTERNAL_FRP_AUTH_SERVER_HOST` | `127.0.0.1` | Master内置 frps 认证服务器主机 |
|
||||||
|
| int | `MASTER_INTERNAL_FRP_AUTH_SERVER_PORT` | `8999` | Master内置 frps 认证服务器端口 |
|
||||||
|
| string | `MASTER_INTERNAL_FRP_AUTH_SERVER_PATH` | `/auth` | Master内置 frps 认证服务器路径 |
|
||||||
|
| int | `SERVER_API_PORT` | `8999` | 服务器 API 端口 |
|
||||||
|
| string | `DB_TYPE` | `sqlite3` | 数据库类型,如 mysql postgres 或 sqlite3 等 |
|
||||||
|
| string | `DB_DSN` | `data.db` | 数据库 DSN,默认使用sqlite3,数据默认存储在可执行文件同目录下,对于 sqlite 是路径,其他数据库为 DSN,参见 [MySQL DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) |
|
||||||
|
| string | `CLIENT_ID` | - | 客户端 ID |
|
||||||
|
| string | `CLIENT_SECRET` | - | 客户端密钥 |
|
||||||
|
|
||||||
|
## 项目开发指南
|
||||||
|
|
||||||
|
### 平台架构设计
|
||||||
|
|
||||||
|
技术栈选好了,下一步就是要设计程序的架构。在刚刚背景里说的那样,frp 本身有 frpc 和 frps(客户端和服务端),这两个角色肯定是必不可少了。然后我们还要新增一个东西去管理它们,所以 frp-panel 新增了一个 master 角色。master 会负责管理各种 frpc 和 frps,中心化的存储配置文件和连接信息。
|
||||||
|
|
||||||
|
然后是 frpc 和 frps。原版是需要在两边分别写配置文件的。那么既然原版已经支持了,就不用在走原版的路子,我们直接不支持配置文件,所有的配置都必须从 master 获取。
|
||||||
|
|
||||||
|
其次还要考虑到与原版的兼容问题,frp-panel 的客户端/服务端都必须要能连上官方 frpc/frps 服务。这样的话就可以做到配置文件/不要配置文件都能完美工作了。
|
||||||
|
总的说来架构还是很简单的。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 开发
|
||||||
|
|
||||||
|
项目包含三个角色
|
||||||
|
|
||||||
|
1. Master: 控制节点,接受来自前端的请求并负责管理 Client 和 Server
|
||||||
|
2. Server: 服务端,受控制节点控制,负责对客户端提供服务,包含 frps 和 rpc(用于连接 Master)服务
|
||||||
|
3. Client: 客户端,受控制节点控制,包含 frpc 和 rpc(用于连接 Master)服务
|
||||||
|
|
||||||
|
接下来给出一个项目中各个包的功能
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
|-- biz # 主要业务逻辑
|
||||||
|
| |-- client # 客户端逻辑(这里指的是frp-panel的客户端)
|
||||||
|
| |-- master # frp-panel 控制平面,负责处理前端请求,并且使用rpc管理frp-panel的server和client
|
||||||
|
| | |-- auth # 认证模块,包含用户认证和客户端认证
|
||||||
|
| | |-- client # 客户端模块,包含前端管理客户端的各种API
|
||||||
|
| | |-- server # 服务端模块,包含前端管理服务端的各种API
|
||||||
|
| | `-- user # 用户模块,包含用户管理、用户信息获取等
|
||||||
|
| `-- server # 服务端逻辑(这里指的是frp-panel的服务端)
|
||||||
|
|-- cache # 缓存,用于存储frps的认证token
|
||||||
|
|-- cmd # 命令行入口,main函数的所在地,负责按需启动各个模块
|
||||||
|
|-- common
|
||||||
|
|-- conf
|
||||||
|
|-- dao # data access object,任何和数据库相关的操作会调用这个库
|
||||||
|
|-- doc # 文档
|
||||||
|
|-- idl # idl定义
|
||||||
|
|-- middleware # api的中间件,包含JWT和context相关,用于处理api请求,鉴权通过后会把用户信息注入到context,可以通过common包获取
|
||||||
|
|-- models # 数据库模型,用于定义数据库表。同时包含实体定义
|
||||||
|
|-- pb # protobuf生成的pb文件
|
||||||
|
|-- rpc # 各种rpc的所在地,包含Client/Server调用Master的逻辑,也包含Master使用Stream调用Client和Server的逻辑
|
||||||
|
|-- services # 各种需要在内存中持久运行的模块,这个包可以管理各个服务的运行/停止
|
||||||
|
| |-- api # api服务,运行需要外部传入一个ginRouter
|
||||||
|
| |-- client # frp的客户端,即frpc,可以控制frpc的各种配置/开始与停止
|
||||||
|
| |-- master # master服务,包含rpc的服务端定义,接收到rpc请求后会调用biz包处理逻辑
|
||||||
|
| |-- rpcclient # 有状态的rpc客户端,因为rpc的client都没有公网ip,因此在rpc client启动时会调用master的stream长连接rpc,建立连接后Master和Client通过这个包通信
|
||||||
|
| `-- server # frp的服务端,即frps,可以控制frps的各种配置/开始与停止
|
||||||
|
|-- tunnel # tunnel模块,用于管理tunnel,也就是管理frpc和frps服务
|
||||||
|
|-- utils
|
||||||
|
|-- watcher # 定时运行的任务,比如每30秒更新一次配置文件
|
||||||
|
`-- www
|
||||||
|
|-- api
|
||||||
|
|-- components # 这里面有一个apitest组件用于测试
|
||||||
|
| `-- ui
|
||||||
|
|-- lib
|
||||||
|
| `-- pb
|
||||||
|
|-- pages
|
||||||
|
|-- public
|
||||||
|
|-- store
|
||||||
|
|-- styles
|
||||||
|
`-- types
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 调试启动方式:
|
||||||
|
|
||||||
|
- master: `go run cmd/*.go master`
|
||||||
|
> client 和 server 的具体参数请复制 master webui 中的内容
|
||||||
|
- client: `go run cmd/*.go client -i <clientID> -s <clientSecret>`
|
||||||
|
- server: `go run cmd/*.go server -i <serverID> -s <serverSecret>`
|
||||||
|
|
||||||
|
项目配置文件会默认读取当前文件夹下的.env 文件,项目内置了样例配置文件,可以按照自己的需求进行修改
|
||||||
|
|
||||||
|
详细架构调用图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本体配置说明
|
||||||
|
|
||||||
|
[settings.go](conf/settings.go)
|
||||||
|
这里有详细的配置参数解释,需要进一步修改配置请参考该文件
|
||||||
|
|
||||||
|
## 截图展示
|
||||||
|
|
||||||
|
### 首页
|
||||||
|

|
||||||
|
|
||||||
|
### 服务器列表
|
||||||
|

|
||||||
|
|
||||||
|
### 服务器编辑
|
||||||
|

|
||||||
|
|
||||||
|
### 服务器高级编辑
|
||||||
|

|
||||||
|
|
||||||
|
### 客户端列表
|
||||||
|

|
||||||
|
|
||||||
|
### 客户端编辑
|
||||||
|

|
||||||
|
|
||||||
|
### 客户端高级编辑
|
||||||
|

|
||||||
|
|
||||||
|
### 客户端统计
|
||||||
|

|
||||||
|
|
||||||
|
### 实时日志
|
||||||
|

|
||||||
|
|
||||||
|
### 远程控制台
|
||||||
|

|
@@ -56,7 +56,7 @@ func GetClientsStatus(c context.Context, req *pb.GetClientsStatusRequest) (*pb.G
|
|||||||
proto.Unmarshal(tresp.GetData(), clientVersion)
|
proto.Unmarshal(tresp.GetData(), clientVersion)
|
||||||
connectTime, ok := mgr.ConnectTime(clientID)
|
connectTime, ok := mgr.ConnectTime(clientID)
|
||||||
if !ok {
|
if !ok {
|
||||||
connectTime = endTime
|
connectTime = time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
resps[clientID] = &pb.ClientStatus{
|
resps[clientID] = &pb.ClientStatus{
|
||||||
@@ -66,7 +66,7 @@ func GetClientsStatus(c context.Context, req *pb.GetClientsStatusRequest) (*pb.G
|
|||||||
Ping: int32(pingTime),
|
Ping: int32(pingTime),
|
||||||
Version: clientVersion,
|
Version: clientVersion,
|
||||||
Addr: lo.ToPtr(mgr.ClientAddr(clientID)),
|
Addr: lo.ToPtr(mgr.ClientAddr(clientID)),
|
||||||
ConnectTime: lo.ToPtr(int32(endTime.Sub(connectTime).Seconds())),
|
ConnectTime: lo.ToPtr(int32(connectTime.UnixMilli())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 451 KiB |
BIN
doc/cn_client_edit.png
Normal file
After Width: | Height: | Size: 292 KiB |
BIN
doc/cn_client_edit_adv.png
Normal file
After Width: | Height: | Size: 333 KiB |
BIN
doc/cn_client_list.png
Normal file
After Width: | Height: | Size: 251 KiB |
BIN
doc/cn_client_stats.png
Normal file
After Width: | Height: | Size: 251 KiB |
BIN
doc/cn_index.png
Normal file
After Width: | Height: | Size: 205 KiB |
BIN
doc/cn_realtime_log.png
Normal file
After Width: | Height: | Size: 491 KiB |
BIN
doc/cn_remote_console.png
Normal file
After Width: | Height: | Size: 683 KiB |
BIN
doc/cn_server_edit.png
Normal file
After Width: | Height: | Size: 306 KiB |
BIN
doc/cn_server_edit_adv.png
Normal file
After Width: | Height: | Size: 357 KiB |
BIN
doc/cn_server_list.png
Normal file
After Width: | Height: | Size: 272 KiB |
Before Width: | Height: | Size: 442 KiB |
Before Width: | Height: | Size: 487 KiB |
Before Width: | Height: | Size: 498 KiB |
Before Width: | Height: | Size: 542 KiB |
Before Width: | Height: | Size: 211 KiB |
Before Width: | Height: | Size: 543 KiB |
BIN
doc/en_client_edit.png
Normal file
After Width: | Height: | Size: 281 KiB |
BIN
doc/en_client_edit_adv.png
Normal file
After Width: | Height: | Size: 320 KiB |
BIN
doc/en_client_list.png
Normal file
After Width: | Height: | Size: 248 KiB |
BIN
doc/en_client_stats.png
Normal file
After Width: | Height: | Size: 252 KiB |
BIN
doc/en_index.png
Normal file
After Width: | Height: | Size: 199 KiB |
BIN
doc/en_realtime_log.png
Normal file
After Width: | Height: | Size: 759 KiB |
BIN
doc/en_remote_console.png
Normal file
After Width: | Height: | Size: 674 KiB |
BIN
doc/en_server_edit.png
Normal file
After Width: | Height: | Size: 300 KiB |
BIN
doc/en_server_edit_adv.png
Normal file
After Width: | Height: | Size: 351 KiB |
BIN
doc/en_server_list.png
Normal file
After Width: | Height: | Size: 267 KiB |
BIN
doc/login.png
Before Width: | Height: | Size: 428 KiB |
Before Width: | Height: | Size: 438 KiB |
BIN
doc/register.png
Before Width: | Height: | Size: 434 KiB |
Before Width: | Height: | Size: 461 KiB |
Before Width: | Height: | Size: 222 KiB |
@@ -6,12 +6,13 @@ import {
|
|||||||
UpdateUserInfoRequest,
|
UpdateUserInfoRequest,
|
||||||
UpdateUserInfoResponse,
|
UpdateUserInfoResponse,
|
||||||
} from '@/lib/pb/api_user'
|
} from '@/lib/pb/api_user'
|
||||||
import { $userInfo } from '@/store/user'
|
import { $statusOnline, $userInfo } from '@/store/user'
|
||||||
import { BaseResponse } from '@/types/api'
|
import { BaseResponse } from '@/types/api'
|
||||||
|
|
||||||
export const getUserInfo = async (req: GetUserInfoRequest) => {
|
export const getUserInfo = async (req: GetUserInfoRequest) => {
|
||||||
const res = await http.post(API_PATH + '/user/get', GetUserInfoRequest.toJson(req))
|
const res = await http.post(API_PATH + '/user/get', GetUserInfoRequest.toJson(req))
|
||||||
$userInfo.set(GetUserInfoResponse.fromJson((res.data as BaseResponse).body).userInfo)
|
$userInfo.set(GetUserInfoResponse.fromJson((res.data as BaseResponse).body).userInfo)
|
||||||
|
$statusOnline.set(!!GetUserInfoResponse.fromJson((res.data as BaseResponse).body).userInfo)
|
||||||
return GetUserInfoResponse.fromJson((res.data as BaseResponse).body)
|
return GetUserInfoResponse.fromJson((res.data as BaseResponse).body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -27,63 +27,17 @@ import { RegisterAndLogin } from "./header"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { getPlatformInfo } from "@/api/platform"
|
import { getPlatformInfo } from "@/api/platform"
|
||||||
|
import { teams, getNavItems } from '@/config/nav'
|
||||||
const data = {
|
import { useTranslation } from 'react-i18next'
|
||||||
teams: [
|
|
||||||
{
|
|
||||||
name: "Frp-Panel",
|
|
||||||
logo: TbBuildingTunnel,
|
|
||||||
plan: "Community Edition",
|
|
||||||
url: "/",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: "客户端",
|
|
||||||
url: "/clients",
|
|
||||||
icon: MonitorSmartphoneIcon,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "服务端",
|
|
||||||
url: "/servers",
|
|
||||||
icon: ServerIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "编辑隧道",
|
|
||||||
url: "/clientedit",
|
|
||||||
icon: MonitorCogIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "编辑服务端",
|
|
||||||
url: "/serveredit",
|
|
||||||
icon: ServerCogIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "流量统计",
|
|
||||||
url: "/clientstats",
|
|
||||||
icon: ChartNetworkIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "实时日志",
|
|
||||||
url: "/streamlog",
|
|
||||||
icon: Scroll,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "控制台",
|
|
||||||
url: "/console",
|
|
||||||
icon: SquareTerminal,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
export interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||||
chrildren?: React.ReactNode
|
children?: React.ReactNode
|
||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: AppSidebarProps) {
|
export function AppSidebar({ ...props }: AppSidebarProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
const userInfo = useStore($userInfo)
|
const userInfo = useStore($userInfo)
|
||||||
const { data: platformInfo } = useQuery({
|
const { data: platformInfo } = useQuery({
|
||||||
queryKey: ['platformInfo'],
|
queryKey: ['platformInfo'],
|
||||||
@@ -107,12 +61,12 @@ export function AppSidebar({ ...props }: AppSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold font-mono">
|
<span className="truncate font-semibold font-mono">
|
||||||
Frp-Panel
|
{t('app.title')}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs font-mono">frp隧道面板</span>
|
<span className="truncate text-xs font-mono">{t('app.subtitle')}</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={getNavItems(t)} />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
@@ -1,14 +1,23 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
import { listClient } from '@/api/client'
|
import { listClient } from '@/api/client'
|
||||||
import { Combobox } from './combobox'
|
import { Combobox } from './combobox'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface ClientSelectorProps {
|
export interface ClientSelectorProps {
|
||||||
clientID?: string
|
clientID?: string
|
||||||
setClientID: (clientID: string) => void
|
setClientID: (clientID: string) => void
|
||||||
onOpenChange?: () => void
|
onOpenChange?: () => void
|
||||||
}
|
}
|
||||||
export const ClientSelector: React.FC<ClientSelectorProps> = ({ clientID, setClientID, onOpenChange }) => {
|
|
||||||
|
export const ClientSelector: React.FC<ClientSelectorProps> = ({
|
||||||
|
clientID,
|
||||||
|
setClientID,
|
||||||
|
onOpenChange
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const handleClientChange = (value: string) => { setClientID(value) }
|
const handleClientChange = (value: string) => { setClientID(value) }
|
||||||
const [keyword, setKeyword] = React.useState('')
|
const [keyword, setKeyword] = React.useState('')
|
||||||
|
|
||||||
@@ -22,8 +31,11 @@ export const ClientSelector: React.FC<ClientSelectorProps> = ({ clientID, setCli
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
placeholder='客户端名称'
|
placeholder={t('selector.client.placeholder')}
|
||||||
dataList={clientList?.clients.map((client) => ({ value: client.id || '', label: client.id || '' })) || []}
|
dataList={clientList?.clients.map((client) => ({
|
||||||
|
value: client.id || '',
|
||||||
|
label: client.id || ''
|
||||||
|
})) || []}
|
||||||
setValue={handleClientChange}
|
setValue={handleClientChange}
|
||||||
value={clientID}
|
value={clientID}
|
||||||
onKeyWordChange={setKeyword}
|
onKeyWordChange={setKeyword}
|
||||||
|
@@ -1,42 +1,70 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Popover, PopoverTrigger } from "@radix-ui/react-popover"
|
import { Popover, PopoverTrigger } from "@radix-ui/react-popover"
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
import { ClientStatus } from "@/lib/pb/api_master"
|
import { ClientStatus } from "@/lib/pb/api_master"
|
||||||
import { PopoverContent } from "../ui/popover"
|
import { PopoverContent } from "../ui/popover"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { zhCN, enUS } from 'date-fns/locale'
|
||||||
|
|
||||||
export const ClientDetail = ({ clientStatus }: { clientStatus: ClientStatus }) => {
|
export const ClientDetail = ({ clientStatus }: { clientStatus: ClientStatus }) => {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
|
const locale = i18n.language === 'zh' ? zhCN : enUS
|
||||||
|
const connectTime = clientStatus.connectTime ?
|
||||||
|
formatDistanceToNow(new Date(clientStatus.connectTime), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale
|
||||||
|
}) : '-'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger className='flex items-center'>
|
<PopoverTrigger className='flex items-center'>
|
||||||
<Badge variant={"secondary"} className='text-nowrap rounded-full h-6'>
|
<Badge
|
||||||
{clientStatus.version?.gitVersion}
|
variant="secondary"
|
||||||
|
className='text-nowrap rounded-full h-6 hover:bg-secondary/80 transition-colors text-sm'
|
||||||
|
>
|
||||||
|
{clientStatus.version?.gitVersion || 'Unknown'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-fit overflow-auto max-w-72 max-h-72 p-4 bg-white rounded-lg shadow-lg">
|
<PopoverContent className="w-72 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-border">
|
||||||
<h3 className="text-lg font-semibold mb-4 text-center">客户端信息</h3>
|
<motion.div
|
||||||
<div className="flex justify-between mb-2">
|
initial={{ opacity: 0, y: -10 }}
|
||||||
<span className="font-medium">版本:</span>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<span>{clientStatus.version?.gitVersion}</span>
|
transition={{ duration: 0.2 }}
|
||||||
</div>
|
>
|
||||||
<div className="flex justify-between mb-2">
|
<h3 className="text-base font-semibold mb-3 text-center text-foreground">
|
||||||
<span className="font-medium">编译时间:</span>
|
{t('client.detail.title')}
|
||||||
<span>{clientStatus.version?.buildDate}</span>
|
</h3>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between mb-2">
|
<div className="flex justify-between items-center py-1 border-b border-border">
|
||||||
<span className="font-medium">Go版本:</span>
|
<span className="text-sm font-medium text-muted-foreground">{t('client.detail.version')}</span>
|
||||||
<span>{clientStatus.version?.goVersion}</span>
|
<span className="text-sm text-foreground">{clientStatus.version?.gitVersion || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mb-2">
|
<div className="flex justify-between items-center py-1 border-b border-border">
|
||||||
<span className="font-medium">客户端平台:</span>
|
<span className="text-sm font-medium text-muted-foreground">{t('client.detail.buildDate')}</span>
|
||||||
<span>{clientStatus.version?.platform}</span>
|
<span className="text-sm text-foreground">{clientStatus.version?.buildDate || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mb-2">
|
<div className="flex justify-between items-center py-1 border-b border-border">
|
||||||
<span className="font-medium">客户端地址:</span>
|
<span className="text-sm font-medium text-muted-foreground">{t('client.detail.goVersion')}</span>
|
||||||
<span>{clientStatus.addr}</span>
|
<span className="text-sm text-foreground">{clientStatus.version?.goVersion || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mb-2">
|
<div className="flex justify-between items-center py-1 border-b border-border">
|
||||||
<span className="font-medium">连接时间:</span>
|
<span className="text-sm font-medium text-muted-foreground">{t('client.detail.platform')}</span>
|
||||||
<span>{clientStatus.connectTime}</span>
|
<span className="text-sm text-foreground">{clientStatus.version?.platform || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-border">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{t('client.detail.address')}</span>
|
||||||
|
<span className="text-sm text-foreground">{clientStatus.addr || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1 border-b border-border">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{t('client.detail.connectTime')}</span>
|
||||||
|
<span className="text-sm text-foreground">{connectTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
|
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Check, ChevronsUpDown } from "lucide-react"
|
import { Check, ChevronsUpDown } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -33,14 +33,30 @@ export interface ComboboxProps {
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Combobox({ value, setValue, dataList, placeholder, notFoundText, onOpenChange, className, keyword, onKeyWordChange, isLoading }: ComboboxProps) {
|
export function Combobox({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
dataList,
|
||||||
|
placeholder,
|
||||||
|
notFoundText,
|
||||||
|
onOpenChange,
|
||||||
|
className,
|
||||||
|
keyword,
|
||||||
|
onKeyWordChange,
|
||||||
|
isLoading
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const debounced = useDebouncedCallback(
|
const debounced = useDebouncedCallback(
|
||||||
(v) => {
|
(v) => {
|
||||||
onKeyWordChange && onKeyWordChange(v as string);
|
onKeyWordChange && onKeyWordChange(v as string);
|
||||||
},
|
},
|
||||||
500,
|
500,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const defaultPlaceholder = t('selector.common.placeholder')
|
||||||
|
const defaultNotFoundText = t('selector.common.notFound')
|
||||||
|
const loadingText = t('selector.common.loading')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={(open) => {
|
<Popover open={open} onOpenChange={(open) => {
|
||||||
@@ -55,15 +71,19 @@ export function Combobox({ value, setValue, dataList, placeholder, notFoundText,
|
|||||||
>
|
>
|
||||||
{value
|
{value
|
||||||
? (dataList.find((item) => item.value === value)?.label || value)
|
? (dataList.find((item) => item.value === value)?.label || value)
|
||||||
: (placeholder||"请选择...")}
|
: (placeholder || defaultPlaceholder)}
|
||||||
<ChevronsUpDown className="opacity-50 h-[12px] w-[12px]" />
|
<ChevronsUpDown className="opacity-50 h-[12px] w-[12px]" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[200px] p-0" align="start">
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput value={keyword} onValueChange={(v) => debounced(v)} placeholder={`${placeholder||"请选择..."}`} />
|
<CommandInput
|
||||||
|
value={keyword}
|
||||||
|
onValueChange={(v) => debounced(v)}
|
||||||
|
placeholder={placeholder || defaultPlaceholder}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{isLoading ? "加载中..." : notFoundText||"未找到结果"}</CommandEmpty>
|
<CommandEmpty>{isLoading ? loadingText : (notFoundText || defaultNotFoundText)}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{dataList.map((item) => (
|
{dataList.map((item) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
@@ -18,6 +18,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { DataTablePagination } from './data_table_pagination'
|
import { DataTablePagination } from './data_table_pagination'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[]
|
columns: ColumnDef<TData, TValue>[]
|
||||||
@@ -27,12 +28,14 @@ interface DataTableProps<TData, TValue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({ columns, filterColumnName, table }: DataTableProps<TData, TValue>) {
|
export function DataTable<TData, TValue>({ columns, filterColumnName, table }: DataTableProps<TData, TValue>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{filterColumnName && (
|
{filterColumnName && (
|
||||||
<div className="flex flex-1 items-center py-4">
|
<div className="flex flex-1 items-center py-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={`根据 ${filterColumnName} 筛选`}
|
placeholder={t('table.filter.placeholder', { column: filterColumnName })}
|
||||||
value={(table.getColumn(filterColumnName)?.getFilterValue() as string) ?? ''}
|
value={(table.getColumn(filterColumnName)?.getFilterValue() as string) ?? ''}
|
||||||
onChange={(event) => table.getColumn(filterColumnName)?.setFilterValue(event.target.value)}
|
onChange={(event) => table.getColumn(filterColumnName)?.setFilterValue(event.target.value)}
|
||||||
className="max-w-sm"
|
className="max-w-sm"
|
||||||
@@ -66,7 +69,7 @@ export function DataTable<TData, TValue>({ columns, filterColumnName, table }: D
|
|||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
没有数据
|
{t('table.noData')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
@@ -1,20 +1,18 @@
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'
|
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'
|
||||||
import { Table } from '@tanstack/react-table'
|
import { Table } from '@tanstack/react-table'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface DataTablePaginationProps<TData> {
|
interface DataTablePaginationProps<TData> {
|
||||||
table: Table<TData>
|
table: Table<TData>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
{/* <div className="flex-1 text-sm text-muted-foreground">
|
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
|
||||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
|
||||||
</div> */}
|
|
||||||
<div className="flex items-center justify-between w-full space-x-6 lg:space-x-8">
|
<div className="flex items-center justify-between w-full space-x-6 lg:space-x-8">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Select
|
<Select
|
||||||
@@ -34,10 +32,13 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-sm font-medium">行 每页</p>
|
<p className="text-sm font-medium">{t('table.pagination.rowsPerPage')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-[120px] items-center justify-center text-sm font-medium">
|
<div className="flex w-[120px] items-center justify-center text-sm font-medium">
|
||||||
第 {table.getState().pagination.pageIndex + 1} 页, 共 {table.getPageCount()}页
|
{t('table.pagination.page', {
|
||||||
|
current: table.getState().pagination.pageIndex + 1,
|
||||||
|
total: table.getPageCount()
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -46,7 +47,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
|
|||||||
onClick={() => table.setPageIndex(0)}
|
onClick={() => table.setPageIndex(0)}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">第一页</span>
|
<span className="sr-only">{t('table.pagination.navigation.first')}</span>
|
||||||
<DoubleArrowLeftIcon className="h-4 w-4" />
|
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -55,7 +56,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
|
|||||||
onClick={() => table.previousPage()}
|
onClick={() => table.previousPage()}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">前一页</span>
|
<span className="sr-only">{t('table.pagination.navigation.previous')}</span>
|
||||||
<ChevronLeftIcon className="h-4 w-4" />
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -64,7 +65,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
|
|||||||
onClick={() => table.nextPage()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">下一页</span>
|
<span className="sr-only">{t('table.pagination.navigation.next')}</span>
|
||||||
<ChevronRightIcon className="h-4 w-4" />
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -73,7 +74,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
|
|||||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">最后页</span>
|
<span className="sr-only">{t('table.pagination.navigation.last')}</span>
|
||||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export interface IdInputProps {
|
export interface IdInputProps {
|
||||||
setKeyword: (keyword: string) => void
|
setKeyword: (keyword: string) => void
|
||||||
@@ -9,13 +12,27 @@ export interface IdInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IdInput: React.FC<IdInputProps> = ({ setKeyword, keyword, refetchTrigger }) => {
|
export const IdInput: React.FC<IdInputProps> = ({ setKeyword, keyword, refetchTrigger }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [input, setInput] = useState(keyword)
|
const [input, setInput] = useState(keyword)
|
||||||
|
|
||||||
return <div className="flex flex-1 flex-row gap-2">
|
return (
|
||||||
<Input className="max-w-40 h-auto" defaultValue={keyword} onChange={(e) => setInput(e.target.value)}></Input>
|
<div className="flex flex-1 flex-row gap-2">
|
||||||
<Button variant="outline" size={'sm'} onClick={() => {
|
<Input
|
||||||
setKeyword(input)
|
className="max-w-40 h-auto"
|
||||||
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
|
defaultValue={keyword}
|
||||||
}}>搜索</Button>
|
placeholder={t('input.id.placeholder')}
|
||||||
</div >
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setKeyword(input)
|
||||||
|
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('input.search')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Combobox } from './combobox'
|
import { Combobox } from './combobox'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface ProxySelectorProps {
|
export interface ProxySelectorProps {
|
||||||
proxyName?: string
|
proxyName?: string
|
||||||
@@ -7,12 +10,23 @@ export interface ProxySelectorProps {
|
|||||||
proxyNames: string[]
|
proxyNames: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProxySelector: React.FC<ProxySelectorProps> = ({ proxyName, proxyNames ,setProxyname }) => {
|
export const ProxySelector: React.FC<ProxySelectorProps> = ({
|
||||||
return <Combobox
|
proxyName,
|
||||||
dataList={proxyNames.map((name) => ({ value: name, label: name }))}
|
proxyNames,
|
||||||
value={proxyName}
|
setProxyname
|
||||||
setValue={setProxyname}
|
}) => {
|
||||||
notFoundText="未找到隧道"
|
const { t } = useTranslation()
|
||||||
placeholder="隧道名称"
|
|
||||||
/>
|
return (
|
||||||
|
<Combobox
|
||||||
|
dataList={proxyNames.map((name) => ({
|
||||||
|
value: name,
|
||||||
|
label: name
|
||||||
|
}))}
|
||||||
|
value={proxyName}
|
||||||
|
setValue={setProxyname}
|
||||||
|
notFoundText={t('selector.proxy.notFound')}
|
||||||
|
placeholder={t('selector.proxy.placeholder')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export interface BaseSelectorProps {
|
export interface BaseSelectorProps {
|
||||||
value?: string
|
value?: string
|
||||||
@@ -21,22 +24,31 @@ export interface BaseSelectorProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseSelector({ value, setValue, dataList, placeholder, label, onOpenChange, className }: BaseSelectorProps) {
|
export function BaseSelector({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
dataList,
|
||||||
|
placeholder,
|
||||||
|
label,
|
||||||
|
onOpenChange,
|
||||||
|
className
|
||||||
|
}: BaseSelectorProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const defaultPlaceholder = t('selector.common.placeholder')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select onValueChange={setValue} value={value} onOpenChange={onOpenChange}>
|
<Select onValueChange={setValue} value={value} onOpenChange={onOpenChange}>
|
||||||
<SelectTrigger className={cn("w-full", className)}>
|
<SelectTrigger className={cn("w-full", className)}>
|
||||||
<SelectValue placeholder={placeholder || "请选择"} />
|
<SelectValue placeholder={placeholder || defaultPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>{label}</SelectLabel>
|
{label && <SelectLabel>{label}</SelectLabel>}
|
||||||
{
|
{dataList.map((item) => (
|
||||||
dataList.map((item) => (
|
<SelectItem key={item.value} value={item.value}>
|
||||||
<SelectItem key={item.value} value={item.value}>
|
{item.label}
|
||||||
{item.label}
|
</SelectItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))
|
|
||||||
}
|
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
@@ -1,14 +1,23 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
import { listServer } from '@/api/server'
|
import { listServer } from '@/api/server'
|
||||||
import { Combobox } from './combobox'
|
import { Combobox } from './combobox'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface ServerSelectorProps {
|
export interface ServerSelectorProps {
|
||||||
serverID?: string
|
serverID?: string
|
||||||
setServerID: (serverID: string) => void
|
setServerID: (serverID: string) => void
|
||||||
onOpenChange?: () => void
|
onOpenChange?: () => void
|
||||||
}
|
}
|
||||||
export const ServerSelector: React.FC<ServerSelectorProps> = ({ serverID, setServerID, onOpenChange }) => {
|
|
||||||
|
export const ServerSelector: React.FC<ServerSelectorProps> = ({
|
||||||
|
serverID,
|
||||||
|
setServerID,
|
||||||
|
onOpenChange
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const handleServerChange = (value: string) => { setServerID(value) }
|
const handleServerChange = (value: string) => { setServerID(value) }
|
||||||
const [keyword, setKeyword] = React.useState('')
|
const [keyword, setKeyword] = React.useState('')
|
||||||
|
|
||||||
@@ -20,15 +29,20 @@ export const ServerSelector: React.FC<ServerSelectorProps> = ({ serverID, setSer
|
|||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (<Combobox
|
return (
|
||||||
placeholder='服务端名称'
|
<Combobox
|
||||||
value={serverID}
|
placeholder={t('selector.server.placeholder')}
|
||||||
setValue={handleServerChange}
|
value={serverID}
|
||||||
dataList={serverList?.servers.map((server) => ({ value: server.id || '', label: server.id || '' })) || []}
|
setValue={handleServerChange}
|
||||||
onKeyWordChange={setKeyword}
|
dataList={serverList?.servers.map((server) => ({
|
||||||
onOpenChange={() => {
|
value: server.id || '',
|
||||||
onOpenChange && onOpenChange()
|
label: server.id || ''
|
||||||
refetchServers()
|
})) || []}
|
||||||
}}
|
onKeyWordChange={setKeyword}
|
||||||
/>)
|
onOpenChange={() => {
|
||||||
|
onOpenChange && onOpenChange()
|
||||||
|
refetchServers()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type Status = 'loading' | 'success' | 'error' ;
|
type Status = 'loading' | 'success' | 'error' ;
|
||||||
|
|
||||||
@@ -13,16 +13,6 @@ const statusColors: Record<Status, { outer: string; inner: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LoadingCircle: React.FC<LoadingCircleProps> = ({ status }) => {
|
const LoadingCircle: React.FC<LoadingCircleProps> = ({ status }) => {
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
setIsVisible((prev) => !prev);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
let { outer, inner } = { outer: 'bg-gray-200', inner: 'bg-gray-500' }
|
let { outer, inner } = { outer: 'bg-gray-200', inner: 'bg-gray-500' }
|
||||||
if (status) {
|
if (status) {
|
||||||
const { outer: o, inner: i } = statusColors[status];
|
const { outer: o, inner: i } = statusColors[status];
|
||||||
@@ -32,8 +22,18 @@ const LoadingCircle: React.FC<LoadingCircleProps> = ({ status }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-6 h-6">
|
<div className="relative flex w-6 h-6">
|
||||||
<div className={`absolute w-6 h-6 rounded-full ${outer} transition-opacity duration-500 ${isVisible ? 'opacity-100' : 'opacity-50'}`}>
|
<div
|
||||||
<div className={`absolute top-1 left-1 w-4 h-4 rounded-full ${inner} transition-opacity duration-500 ${isVisible ? 'opacity-100' : 'opacity-50'}`} />
|
className={`absolute w-6 h-6 rounded-full ${outer} animate-[ping_1.5s_ease-in-out_infinite]`}
|
||||||
|
style={{ animationDelay: '0.2s' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute w-6 h-6 rounded-full ${outer} animate-[ping_1.5s_ease-in-out_infinite]`}
|
||||||
|
style={{ animationDelay: '0.4s' }}
|
||||||
|
/>
|
||||||
|
<div className={`absolute w-6 h-6 rounded-full ${outer}`}>
|
||||||
|
<div
|
||||||
|
className={`absolute top-1 left-1 w-4 h-4 rounded-full ${inner} animate-[pulse_2s_cubic-bezier(0.4,0,0.6,1)_infinite]`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -9,25 +9,28 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export function ProxyTrafficBarChart({ proxyInfo }:{ proxyInfo: ProxyInfo }) {
|
export function ProxyTrafficBarChart({ proxyInfo }:{ proxyInfo: ProxyInfo }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
name: "入站",
|
name: t('traffic.chart.inbound'),
|
||||||
Today: Number(proxyInfo.todayTrafficIn),
|
[t('traffic.chart.today')]: Number(proxyInfo.todayTrafficIn),
|
||||||
History: Number(proxyInfo.historyTrafficIn),
|
[t('traffic.chart.history')]: Number(proxyInfo.historyTrafficIn),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "出站",
|
name: t('traffic.chart.outbound'),
|
||||||
Today: Number(proxyInfo.todayTrafficOut),
|
[t('traffic.chart.today')]: Number(proxyInfo.todayTrafficOut),
|
||||||
History: Number(proxyInfo.historyTrafficOut),
|
[t('traffic.chart.history')]: Number(proxyInfo.historyTrafficOut),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>流量详情</CardTitle>
|
<CardTitle>{t('traffic.chart.title')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={{}} className="h-[300px] w-full font-mono">
|
<ChartContainer config={{}} className="h-[300px] w-full font-mono">
|
||||||
@@ -42,8 +45,8 @@ export function ProxyTrafficBarChart({ proxyInfo }:{ proxyInfo: ProxyInfo }) {
|
|||||||
<YAxis tickFormatter={(value) => formatBytes(Number(value))} />
|
<YAxis tickFormatter={(value) => formatBytes(Number(value))} />
|
||||||
<Tooltip labelClassName="font-mono" wrapperClassName="font-mono" formatter={(value) => formatBytes(Number(value))} />
|
<Tooltip labelClassName="font-mono" wrapperClassName="font-mono" formatter={(value) => formatBytes(Number(value))} />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="Today" fill="hsl(var(--chart-1))" radius={4} />
|
<Bar dataKey={t('traffic.chart.today')} fill="hsl(var(--chart-1))" radius={4} />
|
||||||
<Bar dataKey="History" fill="hsl(var(--chart-2))" radius={4} />
|
<Bar dataKey={t('traffic.chart.history')} fill="hsl(var(--chart-2))" radius={4} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
@@ -51,4 +54,3 @@ export function ProxyTrafficBarChart({ proxyInfo }:{ proxyInfo: ProxyInfo }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,13 +2,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { ProxyInfo } from "@/lib/pb/common"
|
import { ProxyInfo } from "@/lib/pb/common"
|
||||||
import { formatBytes } from "@/lib/utils"
|
import { formatBytes } from "@/lib/utils"
|
||||||
import { CloudDownload, CloudUpload } from "lucide-react"
|
import { CloudDownload, CloudUpload } from "lucide-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
|
export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="tracking-tight text-sm font-medium">今日入站流量</CardTitle>
|
<CardTitle className="tracking-tight text-sm font-medium">{t('traffic.today.inbound')}</CardTitle>
|
||||||
<CloudUpload className="h-4 w-4 text-muted-foreground" />
|
<CloudUpload className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -17,7 +20,7 @@ export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="tracking-tight text-sm font-medium">今日出站流量</CardTitle>
|
<CardTitle className="tracking-tight text-sm font-medium">{t('traffic.today.outbound')}</CardTitle>
|
||||||
<CloudDownload className="h-4 w-4 text-muted-foreground" />
|
<CloudDownload className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -26,7 +29,7 @@ export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="tracking-tight text-sm font-medium">历史入站流量</CardTitle>
|
<CardTitle className="tracking-tight text-sm font-medium">{t('traffic.history.inbound')}</CardTitle>
|
||||||
<CloudUpload className="h-4 w-4 text-muted-foreground" />
|
<CloudUpload className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -35,7 +38,7 @@ export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="tracking-tight text-sm font-medium">历史出站流量</CardTitle>
|
<CardTitle className="tracking-tight text-sm font-medium">{t('traffic.history.outbound')}</CardTitle>
|
||||||
<CloudDownload className="h-4 w-4 text-muted-foreground" />
|
<CloudDownload className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
@@ -9,15 +9,7 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
const chartConfig = {
|
|
||||||
trafficIn: {
|
|
||||||
label: "入站",
|
|
||||||
},
|
|
||||||
trafficOut: {
|
|
||||||
label: "出站",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export function ProxyTrafficPieChart({ trafficIn, trafficOut, title, chartLabel }:
|
export function ProxyTrafficPieChart({ trafficIn, trafficOut, title, chartLabel }:
|
||||||
{ trafficIn: bigint,
|
{ trafficIn: bigint,
|
||||||
@@ -25,6 +17,17 @@ export function ProxyTrafficPieChart({ trafficIn, trafficOut, title, chartLabel
|
|||||||
title: string,
|
title: string,
|
||||||
chartLabel: string,
|
chartLabel: string,
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
trafficIn: {
|
||||||
|
label: t('traffic.chart.pie.inbound'),
|
||||||
|
},
|
||||||
|
trafficOut: {
|
||||||
|
label: t('traffic.chart.pie.outbound'),
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{ type: "trafficIn", data: Number(trafficIn), fill: "hsl(var(--chart-1))" },
|
{ type: "trafficIn", data: Number(trafficIn), fill: "hsl(var(--chart-1))" },
|
||||||
{ type: "trafficOut", data: Number(trafficOut), fill: "hsl(var(--chart-2))" }]
|
{ type: "trafficOut", data: Number(trafficOut), fill: "hsl(var(--chart-2))" }]
|
||||||
@@ -71,4 +74,3 @@ export function ProxyTrafficPieChart({ trafficIn, trafficOut, title, chartLabel
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import i18n from '@/lib/i18n'
|
import i18n from '@/lib/i18n'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
@@ -27,17 +29,17 @@ export const CreateClientDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const handleNewClient = async () => {
|
const handleNewClient = async () => {
|
||||||
toast({ title: t('已提交创建请求') })
|
toast({ title: t('client.create.submitting') })
|
||||||
try {
|
try {
|
||||||
let resp = await newClient.mutateAsync({ clientId: clientID })
|
let resp = await newClient.mutateAsync({ clientId: clientID })
|
||||||
if (resp.status?.code !== RespCode.SUCCESS) {
|
if (resp.status?.code !== RespCode.SUCCESS) {
|
||||||
toast({ title: t('创建客户端失败') })
|
toast({ title: t('client.create.error') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast({ title: t('创建客户端成功') })
|
toast({ title: t('client.create.success') })
|
||||||
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
|
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ title: t('创建客户端失败') })
|
toast({ title: t('client.create.error') })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,19 +47,19 @@ export const CreateClientDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size={'sm'}>
|
<Button variant="outline" size={'sm'}>
|
||||||
{t('新建')}
|
{t('client.create.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('新建客户端')}</DialogTitle>
|
<DialogTitle>{t('client.create.title')}</DialogTitle>
|
||||||
<DialogDescription>{t('创建新的客户端用于连接,客户端ID必须唯一')}</DialogDescription>
|
<DialogDescription>{t('client.create.description')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Label>{t('客户端ID')}</Label>
|
<Label>{t('client.create.id')}</Label>
|
||||||
<Input className="mt-2" value={clientID} onChange={(e) => setClientID(e.target.value)} />
|
<Input className="mt-2" value={clientID} onChange={(e) => setClientID(e.target.value)} />
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={handleNewClient}>{t('创建')}</Button>
|
<Button onClick={handleNewClient}>{t('client.create.submit')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { ColumnDef, Table } from '@tanstack/react-table'
|
import { ColumnDef, Table, TableMeta } from '@tanstack/react-table'
|
||||||
import { MoreHorizontal } from 'lucide-react'
|
import { MoreHorizontal } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -34,8 +34,10 @@ import { ClientType } from '@/lib/pb/common'
|
|||||||
import { ClientStatus, ClientStatus_Status } from '@/lib/pb/api_master'
|
import { ClientStatus, ClientStatus_Status } from '@/lib/pb/api_master'
|
||||||
import { startFrpc, stopFrpc } from '@/api/frp'
|
import { startFrpc, stopFrpc } from '@/api/frp'
|
||||||
import { Badge } from '../ui/badge'
|
import { Badge } from '../ui/badge'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { ClientDetail } from '../base/client_detail'
|
import { ClientDetail } from '../base/client_detail'
|
||||||
import { Input } from '../ui/input'
|
import { Input } from '../ui/input'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export type ClientTableSchema = {
|
export type ClientTableSchema = {
|
||||||
id: string
|
id: string
|
||||||
@@ -46,34 +48,45 @@ export type ClientTableSchema = {
|
|||||||
config?: string
|
config?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TableMetaType extends TableMeta<ClientTableSchema> {
|
||||||
|
refetch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export const columns: ColumnDef<ClientTableSchema>[] = [
|
export const columns: ColumnDef<ClientTableSchema>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'id',
|
accessorKey: 'id',
|
||||||
header: 'ID(点击查看安装命令)',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('client.id')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <ClientID client={row.original} />
|
return <ClientID client={row.original} />
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: '是否配置',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('client.status')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const client = row.original
|
function Cell({ client }: { client: ClientTableSchema }) {
|
||||||
return (
|
const { t } = useTranslation()
|
||||||
<div className={`font-medium ${client.status === 'valid' ? 'text-green-500' : 'text-red-500'} min-w-12`}>
|
return (
|
||||||
{
|
<div className={`font-medium ${client.status === 'valid' ? 'text-green-500' : 'text-red-500'} min-w-12`}>
|
||||||
{
|
{client.status === 'valid' ? t('client.status_configured') : t('client.status_unconfigured')}
|
||||||
valid: '已配置',
|
</div>
|
||||||
invalid: '未配置',
|
)
|
||||||
}[client.status]
|
}
|
||||||
}
|
return <Cell client={row.original} />
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'info',
|
accessorKey: 'info',
|
||||||
header: '运行信息/版本信息',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('client.info')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const client = row.original
|
const client = row.original
|
||||||
return <ClientInfo client={client} />
|
return <ClientInfo client={client} />
|
||||||
@@ -81,7 +94,10 @@ export const columns: ColumnDef<ClientTableSchema>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'secret',
|
accessorKey: 'secret',
|
||||||
header: '连接密钥(点击查看启动命令)',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('client.secret')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const client = row.original
|
const client = row.original
|
||||||
return <ClientSecret client={client} />
|
return <ClientSecret client={client} />
|
||||||
@@ -91,39 +107,78 @@ export const columns: ColumnDef<ClientTableSchema>[] = [
|
|||||||
id: 'action',
|
id: 'action',
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const client = row.original
|
const client = row.original
|
||||||
return <ClientActions client={client} table={table} />
|
return <ClientActions client={client} table={table as Table<ClientTableSchema> & { options: { meta: TableMetaType } }} />
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const ClientID = ({ client }: { client: ClientTableSchema }) => {
|
export const ClientID = ({ client }: { client: ClientTableSchema }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const platformInfo = useStore($platformInfo)
|
const platformInfo = useStore($platformInfo)
|
||||||
|
|
||||||
|
if (!platformInfo) {
|
||||||
|
return (
|
||||||
|
<Button variant="link" className="px-0">
|
||||||
|
{client.id}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<div className="font-mono">{client.id}</div>
|
<Button variant="link" className="px-0 font-mono">
|
||||||
|
{client.id}
|
||||||
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-fit overflow-auto max-w-72 max-h-72 text-nowrap">
|
<PopoverContent className="w-80">
|
||||||
<div>请点击命令框全选复制</div>
|
<div className="grid gap-4">
|
||||||
<div>Linux安装到systemd</div>
|
<div className="space-y-2">
|
||||||
<Input readOnly value={platformInfo === undefined
|
<h4 className="font-medium leading-none">{t('client.install.title')}</h4>
|
||||||
? '获取平台信息失败'
|
<p className="text-sm text-muted-foreground">{t('client.install.description')}</p>
|
||||||
: LinuxInstallCommand('client', client, platformInfo)}></Input>
|
</div>
|
||||||
<div>Windows安装到系统服务</div>
|
<div className="grid gap-2">
|
||||||
<Input readOnly value={
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
platformInfo === undefined
|
<Input
|
||||||
? "获取平台信息失败"
|
readOnly
|
||||||
: WindowsInstallCommand("client", client, platformInfo)
|
value={WindowsInstallCommand('frpc', client, platformInfo)}
|
||||||
}>
|
className="flex-1"
|
||||||
</Input>
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigator.clipboard.writeText(WindowsInstallCommand('frpc', client, platformInfo))}
|
||||||
|
disabled={!platformInfo}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t('client.install.windows')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={LinuxInstallCommand('frpc', client, platformInfo)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigator.clipboard.writeText(LinuxInstallCommand('frpc', client, platformInfo))}
|
||||||
|
disabled={!platformInfo}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t('client.install.linux')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientInfo = ({ client }: { client: ClientTableSchema }) => {
|
export const ClientInfo = ({ client }: { client: ClientTableSchema }) => {
|
||||||
const clientsInfo = useQuery({
|
const { t } = useTranslation()
|
||||||
queryKey: ['getClientsStatus', [client.id]],
|
const { data: clientsStatus } = useQuery({
|
||||||
|
queryKey: ['clientsStatus', client.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await getClientsStatus({
|
return await getClientsStatus({
|
||||||
clientIds: [client.id],
|
clientIds: [client.id],
|
||||||
@@ -133,61 +188,87 @@ export const ClientInfo = ({ client }: { client: ClientTableSchema }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const trans = (info: ClientStatus | undefined) => {
|
const trans = (info: ClientStatus | undefined) => {
|
||||||
let statusText: '在线' | '离线' | '错误' | '暂停' | '未知' = '未知'
|
let statusText: 'client.status_online' | 'client.status_offline' |
|
||||||
|
'client.status_error' | 'client.status_pause' |
|
||||||
|
'client.status_unknown' = 'client.status_unknown'
|
||||||
if (info === undefined) {
|
if (info === undefined) {
|
||||||
return statusText
|
return statusText
|
||||||
}
|
}
|
||||||
if (info.status === ClientStatus_Status.ONLINE) {
|
if (info.status === ClientStatus_Status.ONLINE) {
|
||||||
statusText = '在线'
|
statusText = 'client.status_online'
|
||||||
if (client.stopped) {
|
if (client.stopped) {
|
||||||
statusText = '暂停'
|
statusText = 'client.status_pause'
|
||||||
}
|
}
|
||||||
} else if (info.status === ClientStatus_Status.OFFLINE) {
|
} else if (info.status === ClientStatus_Status.OFFLINE) {
|
||||||
statusText = '离线'
|
statusText = 'client.status_offline'
|
||||||
} else if (info.status === ClientStatus_Status.ERROR) {
|
} else if (info.status === ClientStatus_Status.ERROR) {
|
||||||
statusText = '错误'
|
statusText = 'client.status_error'
|
||||||
}
|
}
|
||||||
return statusText
|
return statusText
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoColor =
|
const infoColor =
|
||||||
clientsInfo.data?.clients[client.id]?.status === ClientStatus_Status.ONLINE ? (
|
clientsStatus?.clients[client.id]?.status === ClientStatus_Status.ONLINE ? (
|
||||||
client.stopped ? 'text-yellow-500' : 'text-green-500') : 'text-red-500'
|
client.stopped ? 'text-yellow-500' : 'text-green-500') : 'text-red-500'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 flex-row">
|
<div className="flex items-center gap-2 flex-row">
|
||||||
<Badge variant={"secondary"} className={`p-2 border rounded font-mono w-fit ${infoColor} text-nowrap rounded-full h-6`}>
|
<Badge variant={"secondary"} className={`p-2 border rounded font-mono w-fit ${infoColor} text-nowrap rounded-full h-6`}>
|
||||||
{`${clientsInfo.data?.clients[client.id].ping}ms,${trans(clientsInfo.data?.clients[client.id])}`}
|
{`${clientsStatus?.clients[client.id].ping}ms,${t(trans(clientsStatus?.clients[client.id]))}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
{clientsInfo.data?.clients[client.id].version &&
|
{clientsStatus?.clients[client.id].version &&
|
||||||
<ClientDetail clientStatus={clientsInfo.data?.clients[client.id]} />
|
<ClientDetail clientStatus={clientsStatus?.clients[client.id]} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientSecret = ({ client }: { client: ClientTableSchema }) => {
|
export const ClientSecret = ({ client }: { client: ClientTableSchema }) => {
|
||||||
const [showSecrect, setShowSecrect] = useState<boolean>(false)
|
const { t } = useTranslation()
|
||||||
const fakeSecret = Array.from({ length: client.secret.length })
|
|
||||||
.map(() => '*')
|
|
||||||
.join('')
|
|
||||||
const platformInfo = useStore($platformInfo)
|
const platformInfo = useStore($platformInfo)
|
||||||
const { toast } = useToast()
|
|
||||||
|
if (!platformInfo) {
|
||||||
|
return (
|
||||||
|
<Button variant="link" className="px-0">
|
||||||
|
{client.secret}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<div
|
<div className="group relative cursor-pointer inline-block font-mono text-nowrap">
|
||||||
onMouseEnter={() => setShowSecrect(true)}
|
<span className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
onMouseLeave={() => setShowSecrect(false)}
|
{client.secret}
|
||||||
className="font-medium hover:rounded hover:bg-slate-100 p-2 font-mono whitespace-nowrap"
|
</span>
|
||||||
>
|
<span className="absolute inset-0 opacity-100 group-hover:opacity-0 transition-opacity duration-200">
|
||||||
{showSecrect ? client.secret : fakeSecret}
|
{'*'.repeat(client.secret.length)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-fit overflow-auto max-w-80">
|
<PopoverContent className="w-[32rem] max-w-[95vw]">
|
||||||
<div>运行命令(需要<a className='text-blue-500' href='https://github.com/VaalaCat/frp-panel/releases'>点击这里</a>自行下载文件)</div>
|
<div className="grid gap-4">
|
||||||
<div className="p-2 border rounded font-mono w-full break-all">
|
<div className="space-y-2">
|
||||||
{platformInfo === undefined ? '获取平台信息失败' : ExecCommandStr('client', client, platformInfo)}
|
<h4 className="font-medium leading-none">{t('client.start.title')}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('client.install.description')} (<a className='text-blue-500' href='https://github.com/VaalaCat/frp-panel/releases' target="_blank" rel="noopener noreferrer">{t('common.download')}</a>)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<pre className="bg-muted p-3 rounded-md font-mono text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{ExecCommandStr('client', client, platformInfo)}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigator.clipboard.writeText(ExecCommandStr('client', client, platformInfo))}
|
||||||
|
disabled={!platformInfo}
|
||||||
|
>
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -200,52 +281,54 @@ export interface ClientItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ClientActions: React.FC<ClientItemProps> = ({ client, table }) => {
|
export const ClientActions: React.FC<ClientItemProps> = ({ client, table }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const platformInfo = useStore($platformInfo)
|
const platformInfo = useStore($platformInfo)
|
||||||
|
|
||||||
|
// placeholder for refetch
|
||||||
const refetchList = () => {}
|
const refetchList = () => {}
|
||||||
|
|
||||||
const removeClient = useMutation({
|
const removeClient = useMutation({
|
||||||
mutationFn: deleteClient,
|
mutationFn: deleteClient,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ description: '删除成功' })
|
toast({ description: t('client.delete.success') })
|
||||||
refetchList()
|
refetchList()
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ description: '删除失败' })
|
toast({ description: t('client.delete.failed') })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const stopClient = useMutation({
|
const stopClient = useMutation({
|
||||||
mutationFn: stopFrpc,
|
mutationFn: stopFrpc,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ description: '停止成功' })
|
toast({ description: t('client.operation.stop_success') })
|
||||||
refetchList()
|
refetchList()
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ description: '停止失败' })
|
toast({ description: t('client.operation.stop_failed') })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const startClient = useMutation({
|
const startClient = useMutation({
|
||||||
mutationFn: startFrpc,
|
mutationFn: startFrpc,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ description: '启动成功' })
|
toast({ description: t('client.operation.start_success') })
|
||||||
refetchList()
|
refetchList()
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ description: '启动失败' })
|
toast({ description: t('client.operation.start_failed') })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const createAndDownloadFile = (fileName: string, content: string) => {
|
const createAndDownloadFile = (fileName: string, content: string) => {
|
||||||
var aTag = document.createElement('a');
|
const aTag = document.createElement('a')
|
||||||
var blob = new Blob([content]);
|
const blob = new Blob([content])
|
||||||
aTag.download = fileName;
|
aTag.download = fileName
|
||||||
aTag.href = URL.createObjectURL(blob);
|
aTag.href = URL.createObjectURL(blob)
|
||||||
aTag.click();
|
aTag.click()
|
||||||
URL.revokeObjectURL(aTag.href);
|
URL.revokeObjectURL(aTag.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -253,27 +336,27 @@ export const ClientActions: React.FC<ClientItemProps> = ({ client, table }) => {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
<span className="sr-only">打开菜单</span>
|
<span className="sr-only">{t('client.actions_menu.open_menu')}</span>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
<DropdownMenuLabel>{t('client.actions_menu.title')}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
try {
|
try {
|
||||||
if (platformInfo) {
|
if (platformInfo) {
|
||||||
navigator.clipboard.writeText(ExecCommandStr('client', client, platformInfo))
|
navigator.clipboard.writeText(ExecCommandStr('client', client, platformInfo))
|
||||||
toast({ description: '复制成功,如果复制不成功,请点击ID字段手动复制' })
|
toast({ description: t('client.actions_menu.copy_success') })
|
||||||
} else {
|
} else {
|
||||||
toast({ description: '获取平台信息失败,如果复制不成功,请点击ID字段手动复制' })
|
toast({ description: t('client.actions_menu.copy_failed') })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ description: '获取平台信息失败,如果复制不成功,请点击ID字段手动复制' })
|
toast({ description: t('client.actions_menu.copy_failed') })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
复制启动命令
|
{t('client.actions_menu.copy_command')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -281,61 +364,68 @@ export const ClientActions: React.FC<ClientItemProps> = ({ client, table }) => {
|
|||||||
router.push({ pathname: '/clientedit', query: { clientID: client.id } })
|
router.push({ pathname: '/clientedit', query: { clientID: client.id } })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
修改配置
|
{t('client.actions_menu.edit_config')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
try {
|
try {
|
||||||
if (platformInfo) {
|
if (platformInfo) {
|
||||||
createAndDownloadFile(`.env`, ClientEnvFile(client, platformInfo))
|
createAndDownloadFile('.env', ClientEnvFile(client, platformInfo))
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
toast({ description: t('client.actions_menu.download_failed') })
|
||||||
toast({ description: '获取平台信息失败' })
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
下载配置
|
{t('client.actions_menu.download_config')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push({ pathname: '/streamlog', query: { clientID: client.id, clientType: ClientType.FRPC.toString() } })
|
router.push({ pathname: '/streamlog', query: { clientID: client.id, clientType: ClientType.FRPC.toString() } })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
实时日志
|
{t('client.actions_menu.realtime_log')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push({ pathname: '/console', query: { clientID: client.id, clientType: ClientType.FRPC.toString() } })
|
router.push({ pathname: '/console', query: { clientID: client.id, clientType: ClientType.FRPC.toString() } })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
远程终端
|
{t('client.actions_menu.remote_terminal')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!client.stopped && <DropdownMenuItem className="text-destructive" onClick={() => stopClient.mutate({ clientId: client.id })}>暂停</DropdownMenuItem>}
|
{!client.stopped && (
|
||||||
{client.stopped && <DropdownMenuItem onClick={() => startClient.mutate({ clientId: client.id })}>启动</DropdownMenuItem>}
|
<DropdownMenuItem className="text-destructive" onClick={() => stopClient.mutate({ clientId: client.id })}>
|
||||||
|
{t('client.actions_menu.pause')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{client.stopped && (
|
||||||
|
<DropdownMenuItem onClick={() => startClient.mutate({ clientId: client.id })}>
|
||||||
|
{t('client.actions_menu.resume')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem className="text-destructive">删除</DropdownMenuItem>
|
<DropdownMenuItem className="text-destructive">{t('client.actions_menu.delete')}</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>确定删除该客户端?</DialogTitle>
|
<DialogTitle>{t('client.delete.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<p className="text-destructive">此操作无法撤消。您确定要永久从我们的服务器中删除该客户端?</p>
|
<p className="text-destructive">{t('client.delete.description')}</p>
|
||||||
<p className="text-gray-500 border-l-4 border-gray-500 pl-4 py-2">
|
<p className="text-gray-500 border-l-4 border-gray-500 pl-4 py-2">
|
||||||
删除后运行中的客户端将无法通过现有参数再次连接,如果您需要删除客户端对外的连接,可以选择暂停客户端
|
{t('client.delete.warning')}
|
||||||
</p>
|
</p>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="submit" onClick={() => removeClient.mutate({ clientId: client.id })}>
|
<Button type="submit" onClick={() => removeClient.mutate({ clientId: client.id })}>
|
||||||
确定
|
{t('client.delete.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog >
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Label } from '@radix-ui/react-label'
|
import { Label } from '@radix-ui/react-label'
|
||||||
@@ -12,15 +14,18 @@ import { ClientConfig } from '@/types/client'
|
|||||||
import { TypedProxyConfig } from '@/types/proxy'
|
import { TypedProxyConfig } from '@/types/proxy'
|
||||||
import { ClientSelector } from '../base/client-selector'
|
import { ClientSelector } from '../base/client-selector'
|
||||||
import { ServerSelector } from '../base/server-selector'
|
import { ServerSelector } from '../base/server-selector'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface FRPCFormCardProps {
|
export interface FRPCFormCardProps {
|
||||||
clientID?: string
|
clientID?: string
|
||||||
serverID?: string
|
serverID?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FRPCFormCard: React.FC<FRPCFormCardProps> = ({
|
export const FRPCFormCard: React.FC<FRPCFormCardProps> = ({
|
||||||
clientID: defaultClientID,
|
clientID: defaultClientID,
|
||||||
serverID: defaultServerID,
|
serverID: defaultServerID,
|
||||||
}: FRPCFormCardProps) => {
|
}: FRPCFormCardProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [advanceMode, setAdvanceMode] = useState<boolean>(false)
|
const [advanceMode, setAdvanceMode] = useState<boolean>(false)
|
||||||
const [clientID, setClientID] = useState<string | undefined>()
|
const [clientID, setClientID] = useState<string | undefined>()
|
||||||
const [serverID, setServerID] = useState<string | undefined>()
|
const [serverID, setServerID] = useState<string | undefined>()
|
||||||
@@ -78,31 +83,31 @@ export const FRPCFormCard: React.FC<FRPCFormCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>编辑隧道</CardTitle>
|
<CardTitle>{t('frpc.form.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div>注意⚠️:选择的「服务端」必须提前配置!</div>
|
<div>{t('frpc.form.description.warning')}</div>
|
||||||
<div>选择客户端和服务端以编辑隧道</div>
|
<div>{t('frpc.form.description.instruction')}</div>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className=" flex items-center space-x-4 rounded-md border p-4">
|
<div className="flex items-center space-x-4 rounded-md border p-4">
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">高级模式</p>
|
<p className="text-sm font-medium leading-none">{t('frpc.form.advanced.title')}</p>
|
||||||
<p className="text-sm text-muted-foreground">编辑客户端原始配置文件</p>
|
<p className="text-sm text-muted-foreground">{t('frpc.form.advanced.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch onCheckedChange={setAdvanceMode} />
|
<Switch onCheckedChange={setAdvanceMode} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-full pt-2 space-y-2">
|
<div className="flex flex-col w-full pt-2 space-y-2">
|
||||||
<Label className="text-sm font-medium">服务端</Label>
|
<Label className="text-sm font-medium">{t('frpc.form.server')}</Label>
|
||||||
<ServerSelector serverID={serverID} setServerID={setServerID} />
|
<ServerSelector serverID={serverID} setServerID={setServerID} />
|
||||||
<Label className="text-sm font-medium">客户端</Label>
|
<Label className="text-sm font-medium">{t('frpc.form.client')}</Label>
|
||||||
<ClientSelector clientID={clientID} setClientID={setClientID} />
|
<ClientSelector clientID={clientID} setClientID={setClientID} />
|
||||||
</div>
|
</div>
|
||||||
{clientID && !advanceMode && <div className='flex flex-col w-full pt-2 space-y-2'>
|
{clientID && !advanceMode && <div className='flex flex-col w-full pt-2 space-y-2'>
|
||||||
<Label className="text-sm font-medium">节点 {clientID} 的备注</Label>
|
<Label className="text-sm font-medium">{t('frpc.form.comment.title', { id: clientID })}</Label>
|
||||||
<p className="text-sm text-muted-foreground">可以到高级模式修改备注哦!</p>
|
<p className="text-sm text-muted-foreground">{t('frpc.form.comment.hint')}</p>
|
||||||
<p className="text-sm border rounded p-2 my-2">
|
<p className="text-sm border rounded p-2 my-2">
|
||||||
{client?.client?.comment == undefined || client?.client?.comment === '' ? '空空如也' : client?.client?.comment}
|
{client?.client?.comment == undefined || client?.client?.comment === '' ? t('frpc.form.comment.empty') : client?.client?.comment}
|
||||||
</p></div>}
|
</p></div>}
|
||||||
{clientID && serverID && !advanceMode && <FRPCForm
|
{clientID && serverID && !advanceMode && <FRPCForm
|
||||||
client={client?.client}
|
client={client?.client}
|
||||||
|
@@ -7,8 +7,10 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { updateFRPC } from '@/api/frp'
|
import { updateFRPC } from '@/api/frp'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { RespCode } from '@/lib/pb/common'
|
import { RespCode } from '@/lib/pb/common'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export const FRPCEditor: React.FC<FRPCFormProps> = ({ clientID, serverID, client, refetchClient }) => {
|
export const FRPCEditor: React.FC<FRPCFormProps> = ({ clientID, serverID, client, refetchClient }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const [configContent, setConfigContent] = useState<string>('{}')
|
const [configContent, setConfigContent] = useState<string>('{}')
|
||||||
@@ -26,12 +28,12 @@ export const FRPCEditor: React.FC<FRPCFormProps> = ({ clientID, serverID, client
|
|||||||
comment: clientComment,
|
comment: clientComment,
|
||||||
})
|
})
|
||||||
if (res.status?.code !== RespCode.SUCCESS) {
|
if (res.status?.code !== RespCode.SUCCESS) {
|
||||||
toast({ title: '更新失败' })
|
toast({ title: t('client.operation.update_failed') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast({ title: '更新成功' })
|
toast({ title: t('client.operation.update_success') })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ title: '更新失败' })
|
toast({ title: t('client.operation.update_failed') })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,22 +68,22 @@ export const FRPCEditor: React.FC<FRPCFormProps> = ({ clientID, serverID, client
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full gap-1.5">
|
<div className="grid w-full gap-1.5">
|
||||||
<Label className="text-sm font-medium">节点 {clientID} 的备注</Label>
|
<Label className="text-sm font-medium">{t('client.editor.comment_title', { id: clientID })}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
key={client?.comment}
|
key={client?.comment}
|
||||||
placeholder="备注"
|
placeholder={t('client.editor.comment_placeholder')}
|
||||||
id="message"
|
id="message"
|
||||||
defaultValue={client?.comment}
|
defaultValue={client?.comment}
|
||||||
onChange={(e) => setClientComment(e.target.value)}
|
onChange={(e) => setClientComment(e.target.value)}
|
||||||
className="h-12"
|
className="h-12"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm font-medium">客户端 {clientID} 配置文件`frpc.json`内容</Label>
|
<Label className="text-sm font-medium">{t('client.editor.config_title', { id: clientID })}</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
只需要配置proxies和visitors字段,认证信息和服务器连接信息会由系统补全
|
{t('client.editor.config_description')}
|
||||||
</p>
|
</p>
|
||||||
<Textarea
|
<Textarea
|
||||||
key={configContent}
|
key={configContent}
|
||||||
placeholder="配置文件内容"
|
placeholder={t('client.editor.config_placeholder')}
|
||||||
id="message"
|
id="message"
|
||||||
defaultValue={configContent}
|
defaultValue={configContent}
|
||||||
onChange={(e) => setEditorValue(e.target.value)}
|
onChange={(e) => setEditorValue(e.target.value)}
|
||||||
@@ -89,7 +91,7 @@ export const FRPCEditor: React.FC<FRPCFormProps> = ({ clientID, serverID, client
|
|||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||||
<Button size="sm" onClick={handleSubmit}>
|
<Button size="sm" onClick={handleSubmit}>
|
||||||
提交
|
{t('common.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
{/* <Button variant="outline" size="sm" onClick={async () => {
|
{/* <Button variant="outline" size="sm" onClick={async () => {
|
||||||
await refetchClient()
|
await refetchClient()
|
||||||
|
@@ -16,6 +16,7 @@ import { QueryObserverResult, RefetchOptions, useMutation } from '@tanstack/reac
|
|||||||
import { updateFRPC } from '@/api/frp'
|
import { updateFRPC } from '@/api/frp'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { GetClientResponse } from '@/lib/pb/api_client'
|
import { GetClientResponse } from '@/lib/pb/api_client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export interface FRPCFormProps {
|
export interface FRPCFormProps {
|
||||||
clientID: string
|
clientID: string
|
||||||
@@ -28,9 +29,11 @@ export interface FRPCFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client, refetchClient, clientProxyConfigs, setClientProxyConfigs }) => {
|
export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client, refetchClient, clientProxyConfigs, setClientProxyConfigs }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [proxyType, setProxyType] = useState<ProxyType>('http')
|
const [proxyType, setProxyType] = useState<ProxyType>('http')
|
||||||
const [proxyName, setProxyName] = useState<string | undefined>()
|
const [proxyName, setProxyName] = useState<string | undefined>()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const handleTypeChange = (value: string) => {
|
const handleTypeChange = (value: string) => {
|
||||||
setProxyType(value as ProxyType)
|
setProxyType(value as ProxyType)
|
||||||
}
|
}
|
||||||
@@ -40,7 +43,10 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
|
|||||||
if (!proxyName) return
|
if (!proxyName) return
|
||||||
if (!proxyType) return
|
if (!proxyType) return
|
||||||
if (clientProxyConfigs.findIndex((proxy) => proxy.name === proxyName) !== -1) {
|
if (clientProxyConfigs.findIndex((proxy) => proxy.name === proxyName) !== -1) {
|
||||||
toast({ title: '创建隧道状态', description: '名称重复' })
|
toast({
|
||||||
|
title: t('proxy.status.create'),
|
||||||
|
description: t('proxy.status.name_exists')
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const newProxy = {
|
const newProxy = {
|
||||||
@@ -70,10 +76,16 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
|
|||||||
clientId: clientID,
|
clientId: clientID,
|
||||||
})
|
})
|
||||||
await refetchClient()
|
await refetchClient()
|
||||||
toast({ title: '更新隧道状态', description: res.status?.code === RespCode.SUCCESS ? '更新成功' : '更新失败' })
|
toast({
|
||||||
|
title: t('proxy.status.update'),
|
||||||
|
description: res.status?.code === RespCode.SUCCESS ? t('proxy.status.success') : t('proxy.status.error')
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast({ title: '更新隧道状态', description: '更新失败' })
|
toast({
|
||||||
|
title: t('proxy.status.update'),
|
||||||
|
description: t('proxy.status.error')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,36 +93,39 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
|
|||||||
<>
|
<>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button className="my-2">新增隧道</Button>
|
<Button className="my-2">{t('proxy.form.add')}</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent>
|
<PopoverContent>
|
||||||
<Label className="text-sm font-medium">名称</Label>
|
<Label className="text-sm font-medium">{t('proxy.form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setProxyName(e.target.value)
|
setProxyName(e.target.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Select onValueChange={handleTypeChange} defaultValue={proxyType}>
|
<Select onValueChange={handleTypeChange} defaultValue={proxyType}>
|
||||||
<Label className="text-sm font-medium">协议</Label>
|
<Label className="text-sm font-medium">{t('proxy.form.protocol')}</Label>
|
||||||
<SelectTrigger className="my-2">
|
<SelectTrigger className="my-2">
|
||||||
<SelectValue placeholder="类型" />
|
<SelectValue placeholder={t('proxy.form.type')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">{t('proxy.type.http')}</SelectItem>
|
||||||
<SelectItem value="tcp">tcp</SelectItem>
|
<SelectItem value="tcp">{t('proxy.type.tcp')}</SelectItem>
|
||||||
<SelectItem value="udp">udp</SelectItem>
|
<SelectItem value="udp">{t('proxy.type.udp')}</SelectItem>
|
||||||
<SelectItem value="stcp">stcp</SelectItem>
|
<SelectItem value="stcp">{t('proxy.type.stcp')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button variant={'outline'} onClick={handleAddProxy}>
|
<Button variant={'outline'} onClick={handleAddProxy}>
|
||||||
确定
|
{t('proxy.form.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Accordion type="single" defaultValue="proxies" collapsible key={clientID + serverID + client}>
|
<Accordion type="single" defaultValue="proxies" collapsible key={clientID + serverID + client}>
|
||||||
<AccordionItem value="proxies">
|
<AccordionItem value="proxies">
|
||||||
<AccordionTrigger>
|
<AccordionTrigger>
|
||||||
<AccordionHeader className="flex flex-row justify-between w-full"><p>隧道配置</p> <p>点击展开{`${clientProxyConfigs.length}条隧道`}</p></AccordionHeader>
|
<AccordionHeader className="flex flex-row justify-between w-full">
|
||||||
|
<p>{t('proxy.form.config')}</p>
|
||||||
|
<p>{t('proxy.form.expand', { count: clientProxyConfigs.length })}</p>
|
||||||
|
</AccordionHeader>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
<AccordionContent className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{clientProxyConfigs.map((item) => {
|
{clientProxyConfigs.map((item) => {
|
||||||
@@ -121,17 +136,17 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
|
|||||||
<Accordion type="single" collapsible>
|
<Accordion type="single" collapsible>
|
||||||
<AccordionItem value={item.name}>
|
<AccordionItem value={item.name}>
|
||||||
<AccordionHeader className="flex flex-row justify-between">
|
<AccordionHeader className="flex flex-row justify-between">
|
||||||
<div>隧道名称:{item.name}</div>
|
<div>{t('proxy.form.tunnel_name')}: {item.name}</div>
|
||||||
<Button
|
<Button
|
||||||
variant={'outline'}
|
variant={'outline'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteProxy(item.name)
|
handleDeleteProxy(item.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
删除
|
{t('proxy.form.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</AccordionHeader>
|
</AccordionHeader>
|
||||||
<AccordionTrigger>类型:「{item.type}」</AccordionTrigger>
|
<AccordionTrigger>{t('proxy.form.type_label', { type: item.type })}</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
{item.type === 'tcp' && serverID && clientID && (
|
{item.type === 'tcp' && serverID && clientID && (
|
||||||
<TCPProxyForm
|
<TCPProxyForm
|
||||||
@@ -183,73 +198,6 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
|
|||||||
})}
|
})}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
{/* <AccordionItem value="visitors">
|
|
||||||
<AccordionTrigger>
|
|
||||||
<AccordionHeader className="flex flex-row justify-between">Visitor 配置</AccordionHeader>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{clientProxyConfigs.map((item) => {
|
|
||||||
return (
|
|
||||||
<Card key={item.name}>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col w-full pt-2">
|
|
||||||
<Accordion type="single" collapsible>
|
|
||||||
<AccordionItem value={item.name}>
|
|
||||||
<AccordionHeader className="flex flex-row justify-between">
|
|
||||||
<div>隧道名称:{item.name}</div>
|
|
||||||
<Button
|
|
||||||
variant={'outline'}
|
|
||||||
onClick={() => {
|
|
||||||
handleDeleteProxy(item.name)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</AccordionHeader>
|
|
||||||
<AccordionTrigger>类型:「{item.type}」</AccordionTrigger>
|
|
||||||
<AccordionContent>
|
|
||||||
{item.type === 'tcp' && serverID && clientID && (
|
|
||||||
<TCPProxyForm
|
|
||||||
defaultProxyConfig={item}
|
|
||||||
proxyName={item.name}
|
|
||||||
serverID={serverID}
|
|
||||||
clientID={clientID}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.type === 'udp' && serverID && clientID && (
|
|
||||||
<UDPProxyForm
|
|
||||||
defaultProxyConfig={item}
|
|
||||||
proxyName={item.name}
|
|
||||||
serverID={serverID}
|
|
||||||
clientID={clientID}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.type === 'http' && serverID && clientID && (
|
|
||||||
<HTTPProxyForm
|
|
||||||
defaultProxyConfig={item}
|
|
||||||
proxyName={item.name}
|
|
||||||
serverID={serverID}
|
|
||||||
clientID={clientID}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.type === 'stcp' && serverID && clientID && (
|
|
||||||
<STCPProxyForm
|
|
||||||
defaultProxyConfig={item}
|
|
||||||
proxyName={item.name}
|
|
||||||
serverID={serverID}
|
|
||||||
clientID={clientID}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem> */}
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Button
|
<Button
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
@@ -257,7 +205,7 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
|
|||||||
handleUpdate()
|
handleUpdate()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
提交变更
|
{t('proxy.form.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@@ -8,13 +8,15 @@ import { Control, FieldValues, useForm } from 'react-hook-form'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { YesIcon } from '@/components/ui/icon'
|
import { YesIcon } from '@/components/ui/icon'
|
||||||
import { Label } from '@/components/ui/label'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getServer } from '@/api/server'
|
import { getServer } from '@/api/server'
|
||||||
import { ServerConfig } from '@/types/server'
|
import { ServerConfig } from '@/types/server'
|
||||||
import { ArrowRightIcon } from 'lucide-react'
|
import { ArrowRightIcon } from 'lucide-react'
|
||||||
|
|
||||||
export const TCPConfigSchema = z.object({
|
export const TCPConfigSchema = z.object({
|
||||||
remotePort: ZodPortSchema,
|
remotePort: ZodPortSchema,
|
||||||
localIP: ZodStringSchema.default('127.0.0.1'),
|
localIP: ZodStringSchema.default('127.0.0.1'),
|
||||||
@@ -59,13 +61,15 @@ const IPField = ({
|
|||||||
label: string
|
label: string
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
name={name}
|
name={name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel>{t(label)}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="127.0.0.1" {...field} />
|
<Input placeholder="127.0.0.1" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -88,13 +92,15 @@ const PortField = ({
|
|||||||
label: string
|
label: string
|
||||||
defaultValue?: number
|
defaultValue?: number
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
name={name}
|
name={name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel>{t(label)}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="8080" {...field} />
|
<Input placeholder="8080" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -117,13 +123,15 @@ const SecretKeyField = ({
|
|||||||
label: string
|
label: string
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
name={name}
|
name={name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel>{t(label)}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="secret key" {...field} />
|
<Input placeholder="secret key" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -168,6 +176,8 @@ export const TCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { data: server } = useQuery({
|
const { data: server } = useQuery({
|
||||||
queryKey: ['getServer', serverID],
|
queryKey: ['getServer', serverID],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -178,7 +188,7 @@ export const TCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Label className="text-sm font-medium">访问方式</Label>
|
<Label className="text-sm font-medium">{t('proxy.form.access_method')}</Label>
|
||||||
{server?.server?.ip && defaultConfig.remotePort && defaultConfig.localIP && defaultConfig.localPort && (
|
{server?.server?.ip && defaultConfig.remotePort && defaultConfig.localIP && defaultConfig.localPort && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -197,24 +207,24 @@ export const TCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
<PortField
|
<PortField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="localPort"
|
name="localPort"
|
||||||
label="本地端口"
|
label="proxy.form.local_port"
|
||||||
defaultValue={defaultConfig?.localPort || 1234}
|
defaultValue={defaultConfig?.localPort || 1234}
|
||||||
/>
|
/>
|
||||||
<IPField
|
<IPField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="localIP"
|
name="localIP"
|
||||||
label="转发地址"
|
label="proxy.form.local_ip"
|
||||||
defaultValue={defaultConfig?.localIP || '127.0.0.1'}
|
defaultValue={defaultConfig?.localIP || '127.0.0.1'}
|
||||||
/>
|
/>
|
||||||
<PortField
|
<PortField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="remotePort"
|
name="remotePort"
|
||||||
label="远端端口"
|
label="proxy.form.remote_port"
|
||||||
defaultValue={defaultConfig?.remotePort || 4321}
|
defaultValue={defaultConfig?.remotePort || 4321}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
||||||
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
||||||
暂存修改
|
{t('proxy.form.save_changes')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -254,6 +264,8 @@ export const STCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { data: server } = useQuery({
|
const { data: server } = useQuery({
|
||||||
queryKey: ['getServer', serverID],
|
queryKey: ['getServer', serverID],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -267,19 +279,19 @@ export const STCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
<PortField
|
<PortField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="localPort"
|
name="localPort"
|
||||||
label="本地端口"
|
label="proxy.form.local_port"
|
||||||
defaultValue={defaultConfig?.localPort || 1234}
|
defaultValue={defaultConfig?.localPort || 1234}
|
||||||
/>
|
/>
|
||||||
<IPField
|
<IPField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="localIP"
|
name="localIP"
|
||||||
label="转发地址"
|
label="proxy.form.local_ip"
|
||||||
defaultValue={defaultConfig?.localIP || '127.0.0.1'}
|
defaultValue={defaultConfig?.localIP || '127.0.0.1'}
|
||||||
/>
|
/>
|
||||||
<SecretKeyField control={form.control} name="secretKey" label="密钥" defaultValue={defaultConfig?.secretKey} />
|
<SecretKeyField control={form.control} name="secretKey" label="proxy.form.secret_key" defaultValue={defaultConfig?.secretKey} />
|
||||||
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
||||||
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
||||||
暂存修改
|
{t('proxy.form.save_changes')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -318,6 +330,8 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { data: server } = useQuery({
|
const { data: server } = useQuery({
|
||||||
queryKey: ['getServer', serverID],
|
queryKey: ['getServer', serverID],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -328,7 +342,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Label className="text-sm font-medium">访问方式</Label>
|
<Label className="text-sm font-medium">{t('proxy.form.access_method')}</Label>
|
||||||
<p className="text-sm border rounded p-2 my-2 font-mono overflow-auto">
|
<p className="text-sm border rounded p-2 my-2 font-mono overflow-auto">
|
||||||
{`${server?.server?.ip}:${(defaultProxyConfig as UDPProxyConfig).remotePort} -> ${
|
{`${server?.server?.ip}:${(defaultProxyConfig as UDPProxyConfig).remotePort} -> ${
|
||||||
defaultProxyConfig?.localIP
|
defaultProxyConfig?.localIP
|
||||||
@@ -339,7 +353,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
name="localPort"
|
name="localPort"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel> 本地端口 </FormLabel>
|
<FormLabel>{t('proxy.form.local_port')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" {...field} />
|
<Input type="number" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -353,7 +367,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
name="localIP"
|
name="localIP"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel> 转发地址 </FormLabel>
|
<FormLabel>{t('proxy.form.local_ip')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -367,7 +381,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
name="remotePort"
|
name="remotePort"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel> 远端端口 </FormLabel>
|
<FormLabel>{t('proxy.form.remote_port')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" {...field} />
|
<Input type="number" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -378,7 +392,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
|
|||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
||||||
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
||||||
暂存修改
|
{t('proxy.form.save_changes')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -418,6 +432,8 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const { data: server } = useQuery({
|
const { data: server } = useQuery({
|
||||||
queryKey: ['getServer', serverID],
|
queryKey: ['getServer', serverID],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -434,7 +450,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<Label className="text-sm font-medium">访问方式</Label>
|
<Label className="text-sm font-medium">{t('proxy.form.access_method')}</Label>
|
||||||
<p className="text-sm border rounded p-2 my-2 font-mono overflow-auto">
|
<p className="text-sm border rounded p-2 my-2 font-mono overflow-auto">
|
||||||
{`http://${(defaultProxyConfig as HTTPProxyConfig).subdomain}.${serverConfig?.subDomainHost}:${
|
{`http://${(defaultProxyConfig as HTTPProxyConfig).subdomain}.${serverConfig?.subDomainHost}:${
|
||||||
serverConfig?.vhostHTTPPort
|
serverConfig?.vhostHTTPPort
|
||||||
@@ -445,7 +461,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
name="localPort"
|
name="localPort"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel> 本地端口 </FormLabel>
|
<FormLabel>{t('proxy.form.local_port')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" {...field} />
|
<Input type="number" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -459,7 +475,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
name="localIP"
|
name="localIP"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel> 转发地址 </FormLabel>
|
<FormLabel>{t('proxy.form.local_ip')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -473,7 +489,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
name="subDomain"
|
name="subDomain"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel> 远端子域名 </FormLabel>
|
<FormLabel>{t('proxy.form.subdomain')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" {...field} />
|
<Input type="text" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -484,7 +500,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
|
|||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
|
||||||
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
|
||||||
暂存修改
|
{t('proxy.form.save_changes')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@@ -8,6 +8,7 @@ import { FRPSEditor } from './frps_editor'
|
|||||||
import FRPSForm from './frps_form'
|
import FRPSForm from './frps_form'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { ServerSelector } from '../base/server-selector'
|
import { ServerSelector } from '../base/server-selector'
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export interface FRPSFormCardProps {
|
export interface FRPSFormCardProps {
|
||||||
serverID?: string
|
serverID?: string
|
||||||
@@ -23,6 +24,7 @@ export const FRPSFormCard: React.FC<FRPSFormCardProps> = ({ serverID: defaultSer
|
|||||||
return getServer({ serverId: serverID })
|
return getServer({ serverId: serverID })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultServerID) {
|
if (defaultServerID) {
|
||||||
@@ -43,27 +45,27 @@ export const FRPSFormCard: React.FC<FRPSFormCardProps> = ({ serverID: defaultSer
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>服务端配置</CardTitle>
|
<CardTitle>{t('server.configuration')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div>
|
<div>
|
||||||
注意⚠️:修改服务端配置文件后,服务端<a className='text-red-600'>会退出</a>
|
{t('server.warning.title')}
|
||||||
<br />如果您使用的是docker容器且启动命令中包含了 --restart=unless-stopped 或 --restart=always 则无需担心。
|
<br />{t('server.warning.dockerHint')}
|
||||||
<br />如果您使用的是systemd安装也无需担心。
|
<br />{t('server.warning.systemdHint')}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
选择服务端以管理Frps服务
|
{t('server.selectHint')}
|
||||||
</div></CardDescription>
|
</div></CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className=" flex items-center space-x-4 rounded-md border p-4">
|
<div className=" flex items-center space-x-4 rounded-md border p-4">
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">高级模式</p>
|
<p className="text-sm font-medium leading-none">{t('server.advancedMode.title')}</p>
|
||||||
<p className="text-sm text-muted-foreground">编辑服务端原始配置文件</p>
|
<p className="text-sm text-muted-foreground">{t('server.advancedMode.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch onCheckedChange={setAdvanceMode} />
|
<Switch onCheckedChange={setAdvanceMode} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-full pt-2">
|
<div className="flex flex-col w-full pt-2">
|
||||||
<Label className="text-sm font-medium">服务端</Label>
|
<Label className="text-sm font-medium">{t('server.serverLabel')}</Label>
|
||||||
<ServerSelector serverID={serverID} setServerID={handleServerChange} onOpenChange={refetchServer} />
|
<ServerSelector serverID={serverID} setServerID={handleServerChange} onOpenChange={refetchServer} />
|
||||||
</div>
|
</div>
|
||||||
{serverID && server && server.server && !advanceMode && <FRPSForm key={serverID} serverID={serverID} server={server.server} />}
|
{serverID && server && server.server && !advanceMode && <FRPSForm key={serverID} serverID={serverID} server={server.server} />}
|
||||||
|
@@ -8,8 +8,10 @@ import { getServer } from '@/api/server'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { updateFRPS } from '@/api/frp'
|
import { updateFRPS } from '@/api/frp'
|
||||||
import { RespCode } from '@/lib/pb/common'
|
import { RespCode } from '@/lib/pb/common'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
|
export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { data: serverResp, refetch: refetchServer } = useQuery({
|
const { data: serverResp, refetch: refetchServer } = useQuery({
|
||||||
queryKey: ['getServer', serverID],
|
queryKey: ['getServer', serverID],
|
||||||
@@ -32,12 +34,12 @@ export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
|
|||||||
comment: serverComment,
|
comment: serverComment,
|
||||||
})
|
})
|
||||||
if (res.status?.code !== RespCode.SUCCESS) {
|
if (res.status?.code !== RespCode.SUCCESS) {
|
||||||
toast({ title: '更新失败' })
|
toast({ title: t('server.operation.update_failed') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast({ title: '更新成功' })
|
toast({ title: t('server.operation.update_success') })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ title: '更新失败' })
|
toast({ title: t('server.operation.update_failed') })
|
||||||
}
|
}
|
||||||
refetchServer()
|
refetchServer()
|
||||||
}
|
}
|
||||||
@@ -76,20 +78,20 @@ export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full gap-1.5">
|
<div className="grid w-full gap-1.5">
|
||||||
<Label className="text-sm font-medium">节点 {serverID} 的备注</Label>
|
<Label className="text-sm font-medium">{t('server.editor.comment', { id: serverID })}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
key={serverResp?.server?.comment}
|
key={serverResp?.server?.comment}
|
||||||
placeholder="备注"
|
placeholder={t('server.editor.comment_placeholder')}
|
||||||
id="message"
|
id="message"
|
||||||
defaultValue={serverResp?.server?.comment}
|
defaultValue={serverResp?.server?.comment}
|
||||||
onChange={(e) => setServerComment(e.target.value)}
|
onChange={(e) => setServerComment(e.target.value)}
|
||||||
className="h-12"
|
className="h-12"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm font-medium">节点 {serverID} 配置文件`frps.json`内容</Label>
|
<Label className="text-sm font-medium">{t('server.editor.config_title', { id: serverID })}</Label>
|
||||||
<p className="text-sm text-muted-foreground">只需要配置端口和IP等字段,认证信息会由系统补全</p>
|
<p className="text-sm text-muted-foreground">{t('server.editor.config_description')}</p>
|
||||||
<Textarea
|
<Textarea
|
||||||
key={configContent}
|
key={configContent}
|
||||||
placeholder="配置文件内容"
|
placeholder={t('server.editor.config_placeholder')}
|
||||||
id="message"
|
id="message"
|
||||||
defaultValue={configContent}
|
defaultValue={configContent}
|
||||||
onChange={(e) => setEditorValue(e.target.value)}
|
onChange={(e) => setEditorValue(e.target.value)}
|
||||||
@@ -97,7 +99,7 @@ export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
|
|||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||||
<Button size="sm" onClick={handleSubmit}>
|
<Button size="sm" onClick={handleSubmit}>
|
||||||
提交
|
{t('common.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -12,6 +12,7 @@ import { updateFRPS } from '@/api/frp'
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { Label } from '@radix-ui/react-label'
|
import { Label } from '@radix-ui/react-label'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const ServerConfigSchema = z.object({
|
const ServerConfigSchema = z.object({
|
||||||
bindAddr: ZodIPSchema.default('0.0.0.0').optional(),
|
bindAddr: ZodIPSchema.default('0.0.0.0').optional(),
|
||||||
@@ -30,6 +31,7 @@ export interface FRPSFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const form = useForm<z.infer<typeof ServerConfigZodSchema>>({
|
const form = useForm<z.infer<typeof ServerConfigZodSchema>>({
|
||||||
resolver: zodResolver(ServerConfigZodSchema),
|
resolver: zodResolver(ServerConfigZodSchema),
|
||||||
})
|
})
|
||||||
@@ -59,21 +61,24 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
toast({
|
toast({
|
||||||
title: resp.status?.code === RespCode.SUCCESS ? '修改成功' : '修改失败',
|
title: resp.status?.code === RespCode.SUCCESS ? t('server.operation.update_success') : t('server.operation.update_failed'),
|
||||||
description: resp.status?.message,
|
description: resp.status?.message,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast({ title: '修改服务端状态', description: '创建失败' })
|
toast({
|
||||||
|
title: t('server.operation.update_title'),
|
||||||
|
description: t('server.operation.update_failed')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full pt-2">
|
<div className="flex flex-col w-full pt-2">
|
||||||
<Label className="text-sm font-medium">节点 {serverID} 的备注</Label>
|
<Label className="text-sm font-medium">{t('server.form.comment_title', { id: serverID })}</Label>
|
||||||
<p className="text-sm text-muted-foreground">可以到高级模式修改备注哦!</p>
|
<p className="text-sm text-muted-foreground">{t('server.form.comment_hint')}</p>
|
||||||
<p className="text-sm border rounded p-2 my-2">
|
<p className="text-sm border rounded p-2 my-2">
|
||||||
{server?.comment == undefined || server?.comment === '' ? '空空如也' : server?.comment}
|
{server?.comment == undefined || server?.comment === '' ? t('server.form.comment_empty') : server?.comment}
|
||||||
</p>
|
</p>
|
||||||
{serverID && (
|
{serverID && (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -83,7 +88,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
name="publicHost"
|
name="publicHost"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>公网地址</FormLabel>
|
<FormLabel>{t('server.form.public_host')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -97,7 +102,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
name="bindPort"
|
name="bindPort"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>FRPs 监听端口</FormLabel>
|
<FormLabel>{t('server.form.bind_port')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" {...field} />
|
<Input type="number" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -111,7 +116,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
name="bindAddr"
|
name="bindAddr"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>FRPs 监听地址</FormLabel>
|
<FormLabel>{t('server.form.bind_addr')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -125,7 +130,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
name="proxyBindAddr"
|
name="proxyBindAddr"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>代理监听地址</FormLabel>
|
<FormLabel>{t('server.form.proxy_bind_addr')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -138,7 +143,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
name="vhostHTTPPort"
|
name="vhostHTTPPort"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>HTTP 监听端口</FormLabel>
|
<FormLabel>{t('server.form.vhost_http_port')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" {...field} />
|
<Input type="number" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -151,7 +156,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
name="subDomainHost"
|
name="subDomainHost"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>域名后缀</FormLabel>
|
<FormLabel>{t('server.form.subdomain_host')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="example.com" {...field} />
|
<Input placeholder="example.com" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -159,7 +164,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit">提交</Button>
|
<Button type="submit">{t('common.submit')}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { initServer } from '@/api/server'
|
import { initServer } from '@/api/server'
|
||||||
@@ -15,8 +17,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export const CreateServerDialog = ({refetchTrigger}: {refetchTrigger?: (randStr: string) => void}) => {
|
export const CreateServerDialog = ({refetchTrigger}: {refetchTrigger?: (randStr: string) => void}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [serverID, setServerID] = useState<string | undefined>()
|
const [serverID, setServerID] = useState<string | undefined>()
|
||||||
const [serverIP, setServerIP] = useState<string | undefined>()
|
const [serverIP, setServerIP] = useState<string | undefined>()
|
||||||
const newServer = useMutation({
|
const newServer = useMutation({
|
||||||
@@ -25,17 +29,17 @@ export const CreateServerDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const handleNewServer = async () => {
|
const handleNewServer = async () => {
|
||||||
toast({ title: '已提交创建请求' })
|
toast({ title: t('server.create.submitting') })
|
||||||
try {
|
try {
|
||||||
let resp = await newServer.mutateAsync({ serverId: serverID, serverIp: serverIP })
|
let resp = await newServer.mutateAsync({ serverId: serverID, serverIp: serverIP })
|
||||||
if (resp.status?.code !== RespCode.SUCCESS) {
|
if (resp.status?.code !== RespCode.SUCCESS) {
|
||||||
toast({ title: '创建服务端失败' })
|
toast({ title: t('server.create.error') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast({ title: '创建服务端成功' })
|
toast({ title: t('server.create.success') })
|
||||||
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
|
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ title: '创建服务端失败' })
|
toast({ title: t('server.create.error') })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,21 +47,21 @@ export const CreateServerDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size={'sm'}>
|
<Button variant="outline" size={'sm'}>
|
||||||
新建
|
{t('server.create.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>新建服务端</DialogTitle>
|
<DialogTitle>{t('server.create.title')}</DialogTitle>
|
||||||
<DialogDescription>创建新的服务端用于提供服务,服务端ID必须唯一</DialogDescription>
|
<DialogDescription>{t('server.create.description')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Label>服务端ID</Label>
|
<Label>{t('server.create.id')}</Label>
|
||||||
<Input className="mt-2" value={serverID} onChange={(e) => setServerID(e.target.value)} />
|
<Input className="mt-2" value={serverID} onChange={(e) => setServerID(e.target.value)} />
|
||||||
<Label>IP地址/域名</Label>
|
<Label>{t('server.create.ip')}</Label>
|
||||||
<Input className="mt-2" value={serverIP} onChange={(e) => setServerIP(e.target.value)} />
|
<Input className="mt-2" value={serverIP} onChange={(e) => setServerIP(e.target.value)} />
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={handleNewServer}>创建</Button>
|
<Button onClick={handleNewServer}>{t('server.create.submit')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@@ -34,11 +34,14 @@ import { ClientType } from '@/lib/pb/common'
|
|||||||
import { ClientStatus, ClientStatus_Status } from '@/lib/pb/api_master'
|
import { ClientStatus, ClientStatus_Status } from '@/lib/pb/api_master'
|
||||||
import { Badge } from '../ui/badge'
|
import { Badge } from '../ui/badge'
|
||||||
import { ClientDetail } from '../base/client_detail'
|
import { ClientDetail } from '../base/client_detail'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
export type ServerTableSchema = {
|
export type ServerTableSchema = {
|
||||||
id: string
|
id: string
|
||||||
status: 'invalid' | 'valid'
|
status: 'invalid' | 'valid'
|
||||||
secret: string
|
secret: string
|
||||||
|
stopped: boolean
|
||||||
info?: string
|
info?: string
|
||||||
ip: string
|
ip: string
|
||||||
config?: string
|
config?: string
|
||||||
@@ -47,76 +50,130 @@ export type ServerTableSchema = {
|
|||||||
export const columns: ColumnDef<ServerTableSchema>[] = [
|
export const columns: ColumnDef<ServerTableSchema>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'id',
|
accessorKey: 'id',
|
||||||
header: 'ID(点击查看安装命令)',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('server.id')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return <ServerID server={row.original} />
|
return <ServerID server={row.original} />
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: '是否配置',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('server.status')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const Server = row.original
|
function Cell({ server }: { server: ServerTableSchema }) {
|
||||||
return (
|
const { t } = useTranslation()
|
||||||
<div className={`font-mono ${Server.status === 'valid' ? 'text-green-500' : 'text-red-500'} min-w-12`}>
|
return (
|
||||||
{
|
<div className={`font-medium ${server.status === 'valid' ? 'text-green-500' : 'text-red-500'} min-w-12`}>
|
||||||
{
|
{server.status === 'valid' ? t('server.status_configured') : t('server.status_unconfigured')}
|
||||||
valid: '已配置',
|
</div>
|
||||||
invalid: '未配置',
|
)
|
||||||
}[Server.status]
|
}
|
||||||
}
|
return <Cell server={row.original} />
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'info',
|
accessorKey: 'info',
|
||||||
header: '运行信息/版本信息',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('server.info')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const Server = row.original
|
const server = row.original
|
||||||
return <ServerInfo server={Server} />
|
return <ServerInfo server={server} />
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'ip',
|
accessorKey: 'ip',
|
||||||
header: 'IP/域名',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('server.ip')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const Server = row.original
|
return row.original.ip
|
||||||
return <div className="font-mono">{Server.ip}</div>
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'secret',
|
accessorKey: 'secret',
|
||||||
header: '连接密钥(点击查看启动命令)',
|
header: function Header() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return t('server.secret')
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const Server = row.original
|
const server = row.original
|
||||||
return <ServerSecret server={Server} />
|
return <ServerSecret server={server} />
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'action',
|
id: 'action',
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const Server = row.original
|
const server = row.original
|
||||||
return <ServerActions server={Server} table={table} />
|
return <ServerActions server={server} table={table as Table<ServerTableSchema>} />
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const ServerID = ({ server }: { server: ServerTableSchema }) => {
|
export const ServerID = ({ server }: { server: ServerTableSchema }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const platformInfo = useStore($platformInfo)
|
const platformInfo = useStore($platformInfo)
|
||||||
|
|
||||||
|
if (!platformInfo) {
|
||||||
|
return (
|
||||||
|
<Button variant="link" className="px-0">
|
||||||
|
{server.id}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<div className="font-mono">{server.id}</div>
|
<Button variant="link" className="px-0 font-mono">
|
||||||
|
{server.id}
|
||||||
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-fit overflow-auto max-w-72 max-h-72">
|
<PopoverContent className="w-80">
|
||||||
<div>Linux安装到systemd</div>
|
<div className="grid gap-4">
|
||||||
<div className="p-2 border rounded font-mono w-fit">
|
<div className="space-y-2">
|
||||||
{platformInfo === undefined ? '获取平台信息失败' : LinuxInstallCommand('server', server, platformInfo)}
|
<h4 className="font-medium leading-none">{t('server.install.title')}</h4>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">{t('server.install.description')}</p>
|
||||||
<div>Windows安装到系统服务</div>
|
</div>
|
||||||
<div className="p-2 border rounded font-mono w-fit">
|
<div className="grid gap-2">
|
||||||
{platformInfo === undefined ? '获取平台信息失败' : WindowsInstallCommand('server', server, platformInfo)}
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={WindowsInstallCommand('server', server, platformInfo)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigator.clipboard.writeText(WindowsInstallCommand('server', server, platformInfo))}
|
||||||
|
disabled={!platformInfo}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t('server.install.windows')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 items-center gap-4">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={LinuxInstallCommand('server', server, platformInfo)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigator.clipboard.writeText(LinuxInstallCommand('server', server, platformInfo))}
|
||||||
|
disabled={!platformInfo}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t('server.install.linux')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -124,8 +181,9 @@ export const ServerID = ({ server }: { server: ServerTableSchema }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ServerInfo = ({ server }: { server: ServerTableSchema }) => {
|
export const ServerInfo = ({ server }: { server: ServerTableSchema }) => {
|
||||||
const clientsInfo = useQuery({
|
const { t } = useTranslation()
|
||||||
queryKey: ['getClientsStatus', [server.id]],
|
const { data: clientsStatus } = useQuery({
|
||||||
|
queryKey: ['clientsStatus', server.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await getClientsStatus({
|
return await getClientsStatus({
|
||||||
clientIds: [server.id],
|
clientIds: [server.id],
|
||||||
@@ -135,58 +193,87 @@ export const ServerInfo = ({ server }: { server: ServerTableSchema }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const trans = (info: ClientStatus | undefined) => {
|
const trans = (info: ClientStatus | undefined) => {
|
||||||
let statusText: '在线' | '离线' | '错误' | '未知' = '未知'
|
let statusText: 'server.status_online' | 'server.status_offline' |
|
||||||
|
'server.status_error' | 'server.status_pause' |
|
||||||
|
'server.status_unknown' = 'server.status_unknown'
|
||||||
if (info === undefined) {
|
if (info === undefined) {
|
||||||
return statusText
|
return statusText
|
||||||
}
|
}
|
||||||
if (info.status === ClientStatus_Status.ONLINE) {
|
if (info.status === ClientStatus_Status.ONLINE) {
|
||||||
statusText = '在线'
|
statusText = 'server.status_online'
|
||||||
|
if (server.stopped) {
|
||||||
|
statusText = 'server.status_pause'
|
||||||
|
}
|
||||||
} else if (info.status === ClientStatus_Status.OFFLINE) {
|
} else if (info.status === ClientStatus_Status.OFFLINE) {
|
||||||
statusText = '离线'
|
statusText = 'server.status_offline'
|
||||||
} else if (info.status === ClientStatus_Status.ERROR) {
|
} else if (info.status === ClientStatus_Status.ERROR) {
|
||||||
statusText = '错误'
|
statusText = 'server.status_error'
|
||||||
}
|
}
|
||||||
return statusText
|
return statusText
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoColor =
|
const infoColor =
|
||||||
clientsInfo.data?.clients[server.id]?.status === ClientStatus_Status.ONLINE ? 'text-green-500' : 'text-red-500'
|
clientsStatus?.clients[server.id]?.status === ClientStatus_Status.ONLINE ? (
|
||||||
|
server.stopped ? 'text-yellow-500' : 'text-green-500') : 'text-red-500'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 flex-row">
|
<div className="flex items-center gap-2 flex-row">
|
||||||
<Badge variant={"secondary"} className={`p-2 border rounded font-mono w-fit ${infoColor} text-nowrap rounded-full h-6`}>
|
<Badge variant={"secondary"} className={`p-2 border rounded font-mono w-fit ${infoColor} text-nowrap rounded-full h-6`}>
|
||||||
{`${clientsInfo.data?.clients[server.id].ping}ms,${trans(clientsInfo.data?.clients[server.id])}`}
|
{`${clientsStatus?.clients[server.id]?.ping}ms,${t(trans(clientsStatus?.clients[server.id]))}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
{clientsInfo.data?.clients[server.id].version &&
|
{clientsStatus?.clients[server.id]?.version &&
|
||||||
<ClientDetail clientStatus={clientsInfo.data?.clients[server.id]} />
|
<ClientDetail clientStatus={clientsStatus?.clients[server.id]} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerSecret = ({ server }: { server: ServerTableSchema }) => {
|
export const ServerSecret = ({ server }: { server: ServerTableSchema }) => {
|
||||||
const [showSecrect, setShowSecrect] = useState<boolean>(false)
|
const { t } = useTranslation()
|
||||||
const fakeSecret = Array.from({ length: server.secret.length })
|
|
||||||
.map(() => '*')
|
|
||||||
.join('')
|
|
||||||
const { toast } = useToast()
|
|
||||||
const platformInfo = useStore($platformInfo)
|
const platformInfo = useStore($platformInfo)
|
||||||
|
|
||||||
|
if (!platformInfo) {
|
||||||
|
return (
|
||||||
|
<Button variant="link" className="px-0">
|
||||||
|
{server.secret}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<div
|
<div className="group relative cursor-pointer inline-block font-mono text-nowrap">
|
||||||
onMouseEnter={() => setShowSecrect(true)}
|
<span className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
onMouseLeave={() => setShowSecrect(false)}
|
{server.secret}
|
||||||
className="font-medium hover:rounded hover:bg-slate-100 p-2 font-mono whitespace-nowrap"
|
</span>
|
||||||
>
|
<span className="absolute inset-0 opacity-100 group-hover:opacity-0 transition-opacity duration-200">
|
||||||
{showSecrect ? server.secret : fakeSecret}
|
{'*'.repeat(server.secret.length)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-fit overflow-auto max-w-48">
|
<PopoverContent className="w-[32rem] max-w-[95vw]">
|
||||||
<div>运行命令(需要<a className='text-blue-500' href='https://github.com/VaalaCat/frp-panel/releases'>点击这里</a>自行下载文件)</div>
|
<div className="grid gap-4">
|
||||||
<div className="p-2 border rounded font-mono w-full break-all">
|
<div className="space-y-2">
|
||||||
{platformInfo === undefined ? '获取平台信息失败' : ExecCommandStr('server', server, platformInfo)}
|
<h4 className="font-medium leading-none">{t('server.start.title')}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('server.install.description')} (<a className='text-blue-500' href='https://github.com/VaalaCat/frp-panel/releases' target="_blank" rel="noopener noreferrer">{t('common.download')}</a>)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<pre className="bg-muted p-3 rounded-md font-mono text-sm overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{ExecCommandStr('server', server, platformInfo)}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigator.clipboard.writeText(ExecCommandStr('server', server, platformInfo))}
|
||||||
|
disabled={!platformInfo}
|
||||||
|
>
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -199,30 +286,31 @@ export interface ServerItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ServerActions: React.FC<ServerItemProps> = ({ server, table }) => {
|
export const ServerActions: React.FC<ServerItemProps> = ({ server, table }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const platformInfo = useStore($platformInfo)
|
const platformInfo = useStore($platformInfo)
|
||||||
|
|
||||||
const refetchList = () => { }
|
const refetchList = () => {}
|
||||||
|
|
||||||
const removeServer = useMutation({
|
const removeServer = useMutation({
|
||||||
mutationFn: deleteServer,
|
mutationFn: deleteServer,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ description: '删除成功' })
|
toast({ description: t('server.delete.success') })
|
||||||
refetchList()
|
refetchList()
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ description: '删除失败' })
|
toast({ description: t('server.delete.failed') })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const createAndDownloadFile = (fileName: string, content: string) => {
|
const createAndDownloadFile = (fileName: string, content: string) => {
|
||||||
var aTag = document.createElement('a');
|
const aTag = document.createElement('a')
|
||||||
var blob = new Blob([content]);
|
const blob = new Blob([content])
|
||||||
aTag.download = fileName;
|
aTag.download = fileName
|
||||||
aTag.href = URL.createObjectURL(blob);
|
aTag.href = URL.createObjectURL(blob)
|
||||||
aTag.click();
|
aTag.click()
|
||||||
URL.revokeObjectURL(aTag.href);
|
URL.revokeObjectURL(aTag.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -230,88 +318,82 @@ export const ServerActions: React.FC<ServerItemProps> = ({ server, table }) => {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
<span className="sr-only">打开菜单</span>
|
<span className="sr-only">{t('server.actions_menu.open_menu')}</span>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
<DropdownMenuLabel>{t('server.actions_menu.title')}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
try {
|
try {
|
||||||
if (platformInfo) {
|
if (platformInfo) {
|
||||||
navigator.clipboard.writeText(ExecCommandStr('server', server, platformInfo))
|
navigator.clipboard.writeText(ExecCommandStr('server', server, platformInfo))
|
||||||
toast({ description: '复制成功,如果复制不成功,请点击ID字段手动复制' })
|
toast({ description: t('server.actions_menu.copy_success') })
|
||||||
} else {
|
} else {
|
||||||
toast({ description: '获取平台信息失败,如果复制不成功,请点击ID字段手动复制' })
|
toast({ description: t('server.actions_menu.copy_failed') })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ description: '获取平台信息失败,如果复制不成功,请点击ID字段手动复制' })
|
toast({ description: t('server.actions_menu.copy_failed') })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
复制启动命令
|
{t('server.actions_menu.copy_command')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push({
|
router.push({ pathname: '/serveredit', query: { serverID: server.id } })
|
||||||
pathname: '/serveredit',
|
|
||||||
query: {
|
|
||||||
serverID: server.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
编辑服务端
|
{t('server.actions_menu.edit_config')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
try {
|
try {
|
||||||
if (platformInfo) {
|
if (platformInfo) {
|
||||||
createAndDownloadFile(`.env`, ClientEnvFile(server, platformInfo))
|
createAndDownloadFile('.env', ClientEnvFile(server, platformInfo))
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
toast({ description: t('server.actions_menu.download_failed') })
|
||||||
toast({ description: '获取平台信息失败' })
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
下载配置
|
{t('server.actions_menu.download_config')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push({ pathname: '/streamlog', query: { clientID: server.id, clientType: ClientType.FRPS.toString() } })
|
router.push({ pathname: '/streamlog', query: { serverID: server.id, clientType: ClientType.FRPS.toString() } })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
实时日志
|
{t('server.actions_menu.realtime_log')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push({ pathname: '/console', query: { clientID: server.id, clientType: ClientType.FRPS.toString() } })
|
router.push({ pathname: '/console', query: { serverID: server.id, clientType: ClientType.FRPS.toString() } })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
远程终端
|
{t('server.actions_menu.remote_terminal')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem className="text-destructive">删除</DropdownMenuItem>
|
<DropdownMenuItem className="text-destructive">{t('server.actions_menu.delete')}</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>确定删除该服务端?</DialogTitle>
|
<DialogTitle>{t('server.delete.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<p className="text-destructive">此操作无法撤消。您确定要永久从我们的服务器中删除该客户端?</p>
|
<p className="text-destructive">{t('server.delete.description')}</p>
|
||||||
<p className="text-gray-500 border-l-4 border-gray-500 pl-4 py-2">
|
<p className="text-gray-500 border-l-4 border-gray-500 pl-4 py-2">
|
||||||
删除后运行中的服务端将无法通过现有参数再次连接,如果您需要停止服务端的服务,可以选择清空配置
|
{t('server.delete.warning')}
|
||||||
</p>
|
</p>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button type="submit" onClick={() => removeServer.mutate({ serverId: server.id })}>
|
<Button type="submit" onClick={() => removeServer.mutate({ serverId: server.id })}>
|
||||||
确定
|
{t('server.delete.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
@@ -1,19 +1,55 @@
|
|||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { $platformInfo, $userInfo } from '@/store/user'
|
import { $platformInfo, $userInfo, $statusOnline, $token } from '@/store/user'
|
||||||
import { getUserInfo } from '@/api/user'
|
import { getUserInfo } from '@/api/user'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { getPlatformInfo } from '@/api/platform'
|
import { getPlatformInfo } from '@/api/platform'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { LanguageSwitcher } from './language-switcher'
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = ({ title }: { title?: string }) => {
|
||||||
return (<></>)
|
const router = useRouter()
|
||||||
|
const isOnline = useStore($statusOnline)
|
||||||
|
const token = useStore($token)
|
||||||
|
const [isInitializing, setIsInitializing] = useState(true)
|
||||||
|
const currentPath = router.pathname
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 设置5秒延迟,等待状态初始化
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsInitializing(false)
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isOnline, token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 只有在初始化完成后才进行状态检查和跳转
|
||||||
|
if (!isInitializing) {
|
||||||
|
console.log('isInitializing', isOnline, token, currentPath)
|
||||||
|
// 如果用户未登录且不在登录/注册页面,则跳转到登录页
|
||||||
|
const isAuthPage = ['/login', '/register'].includes(currentPath)
|
||||||
|
if ((!token || !isOnline) && !isAuthPage) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [token, isOnline, router, isInitializing, currentPath])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full justify-between items-center gap-2">
|
||||||
|
{title && <p className='font-bold'>{title}</p>}
|
||||||
|
{!title && <p></p>}
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RegisterAndLogin = () => {
|
export const RegisterAndLogin = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userInfo = useStore($userInfo)
|
const userInfo = useStore($userInfo)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const platformInfo = useQuery({
|
const platformInfo = useQuery({
|
||||||
queryKey: ['platformInfo'],
|
queryKey: ['platformInfo'],
|
||||||
@@ -31,18 +67,19 @@ export const RegisterAndLogin = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
$userInfo.set(userInfoQuery.data?.userInfo)
|
$userInfo.set(userInfoQuery.data?.userInfo)
|
||||||
|
$statusOnline.set(!!userInfoQuery.data?.userInfo)
|
||||||
}, [userInfoQuery])
|
}, [userInfoQuery])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!userInfo && (
|
{!userInfo && (
|
||||||
<Button variant={'ghost'} onClick={() => router.push('/login')}>
|
<Button variant="ghost" size="sm" onClick={() => router.push('/login')}>
|
||||||
登录
|
{t('common.login')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!userInfo && (
|
{!userInfo && (
|
||||||
<Button variant={'ghost'} onClick={() => router.push('/register')}>
|
<Button variant="ghost" size="sm" onClick={() => router.push('/register')}>
|
||||||
注册
|
{t('common.register')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
52
www/components/language-switcher.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Languages } from 'lucide-react'
|
||||||
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { $language } from '@/store/user'
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const currentLanguage = useStore($language)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
i18n.changeLanguage(currentLanguage)
|
||||||
|
}, [currentLanguage, i18n])
|
||||||
|
|
||||||
|
const toggleLanguage = (lang: string) => {
|
||||||
|
$language.set(lang)
|
||||||
|
i18n.changeLanguage(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Languages className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => toggleLanguage('zh')}
|
||||||
|
className={currentLanguage === 'zh' ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => toggleLanguage('en')}
|
||||||
|
className={currentLanguage === 'en' ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
@@ -6,7 +6,7 @@ import {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function RootLayout({
|
export function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -20,21 +20,22 @@ export function RootLayout({
|
|||||||
mainHeader?: React.ReactNode
|
mainHeader?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { open, isMobile } = useSidebar()
|
const { open, isMobile } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppSidebar footer={sidebarFooter} >{sidebarItems} </AppSidebar>
|
<AppSidebar footer={sidebarFooter}>{sidebarItems}</AppSidebar>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<div className={cn("relative flex flex-1 flex-col overflow-hidden transition-all",
|
<div className={cn("relative flex flex-1 flex-col overflow-hidden transition-all",
|
||||||
isMobile && "w-[100dvw]",
|
isMobile && "w-[100dvw]",
|
||||||
!isMobile && open && "w-[calc(100dvw-var(--sidebar-width))]",
|
!isMobile && open && "w-[calc(100dvw-var(--sidebar-width))]",
|
||||||
!isMobile && !open && "w-[calc(100dvw-var(--sidebar-width-icon))]"
|
!isMobile && !open && "w-[calc(100dvw-var(--sidebar-width-icon))]"
|
||||||
)}>
|
)}>
|
||||||
<header className="flex flex-row h-12 items-center gap-2 w-full">
|
<header className="flex flex-row h-12 items-center gap-2 w-full pr-4">
|
||||||
<div className="flex flex-row items-center gap-2 px-4 w-full">
|
<div className="flex flex-row items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
{mainHeader}
|
|
||||||
</div>
|
</div>
|
||||||
|
{mainHeader}
|
||||||
</header>
|
</header>
|
||||||
<div id="main-content"
|
<div id="main-content"
|
||||||
className="h-[calc(100dvh_-_48px)] overflow-auto w-full pb-4 px-4 pt-2">
|
className="h-[calc(100dvh_-_48px)] overflow-auto w-full pb-4 px-4 pt-2">
|
||||||
|
@@ -2,7 +2,7 @@ import { ZodStringSchema } from '@/lib/consts'
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from '@/components/ui/form'
|
||||||
import { Input } from './ui/input'
|
import { Input } from './ui/input'
|
||||||
import { login } from '@/api/auth'
|
import { login } from '@/api/auth'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
@@ -14,13 +14,15 @@ import { useState } from 'react'
|
|||||||
import { useToast } from './ui/use-toast'
|
import { useToast } from './ui/use-toast'
|
||||||
import { RespCode } from '@/lib/pb/common'
|
import { RespCode } from '@/lib/pb/common'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export const LoginSchema = z.object({
|
export const LoginSchema = z.object({
|
||||||
username: ZodStringSchema,
|
username: ZodStringSchema,
|
||||||
password: ZodStringSchema,
|
password: ZodStringSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const LoginComponent = () => {
|
export function LoginComponent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const form = useForm<z.infer<typeof LoginSchema>>({
|
const form = useForm<z.infer<typeof LoginSchema>>({
|
||||||
resolver: zodResolver(LoginSchema),
|
resolver: zodResolver(LoginSchema),
|
||||||
})
|
})
|
||||||
@@ -30,21 +32,21 @@ export const LoginComponent = () => {
|
|||||||
const [loginAlert, setLoginAlert] = useState(false)
|
const [loginAlert, setLoginAlert] = useState(false)
|
||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
|
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
|
||||||
toast({ title: '登录中,请稍候' })
|
toast({ title: t('auth.loggingIn') })
|
||||||
try {
|
try {
|
||||||
const res = await login({ ...values })
|
const res = await login({ ...values })
|
||||||
if (res.status?.code === RespCode.SUCCESS) {
|
if (res.status?.code === RespCode.SUCCESS) {
|
||||||
toast({ title: '登录成功,正在跳转到首页' })
|
toast({ title: t('auth.loginSuccess') })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}, 3000)
|
}, 3000)
|
||||||
setLoginAlert(false)
|
setLoginAlert(false)
|
||||||
} else {
|
} else {
|
||||||
toast({ title: '登录失败' })
|
toast({ title: t('auth.loginFailed') })
|
||||||
setLoginAlert(true)
|
setLoginAlert(true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '登录失败' })
|
toast({ title: t('auth.loginFailed') })
|
||||||
console.log('login error', e)
|
console.log('login error', e)
|
||||||
setLoginAlert(true)
|
setLoginAlert(true)
|
||||||
}
|
}
|
||||||
@@ -59,8 +61,9 @@ export const LoginComponent = () => {
|
|||||||
name="username"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
<FormLabel>{t('auth.usernamePlaceholder')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="用户名" {...field} />
|
<Input type="text" placeholder={t('auth.usernamePlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -71,8 +74,9 @@ export const LoginComponent = () => {
|
|||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
<FormLabel>{t('auth.password')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="密码" {...field} />
|
<Input type="password" placeholder={t('auth.passwordPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -81,11 +85,13 @@ export const LoginComponent = () => {
|
|||||||
{loginAlert && (
|
{loginAlert && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||||
<AlertTitle>错误</AlertTitle>
|
<AlertTitle>{t('auth.error')}</AlertTitle>
|
||||||
<AlertDescription>登录失败,请重试</AlertDescription>
|
<AlertDescription>{t('auth.loginFailed')}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Button type="submit">登录</Button>
|
<Button className="w-full" type="submit">
|
||||||
|
{t('common.login')}
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -24,12 +24,14 @@ import { Avatar } from "./ui/avatar"
|
|||||||
import { UserAvatar } from "./base/avatar"
|
import { UserAvatar } from "./base/avatar"
|
||||||
import { $token, $userInfo } from "@/store/user"
|
import { $token, $userInfo } from "@/store/user"
|
||||||
import { logout } from "@/api/auth"
|
import { logout } from "@/api/auth"
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function NavUser({
|
export interface NavUserProps {
|
||||||
user,
|
|
||||||
}: {
|
|
||||||
user: User
|
user: User
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function NavUser({ user }: NavUserProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,8 +47,8 @@ export function NavUser({
|
|||||||
<UserAvatar className="w-8 h-8" userInfo={user} />
|
<UserAvatar className="w-8 h-8" userInfo={user} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{user.userName}</span>
|
<span className="truncate font-semibold">{user?.userName}</span>
|
||||||
<span className="truncate text-xs">{user.email}</span>
|
<span className="truncate text-xs">{user?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className="ml-auto size-4" />
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -63,8 +65,8 @@ export function NavUser({
|
|||||||
<UserAvatar className="w-8 h-8" userInfo={user} />
|
<UserAvatar className="w-8 h-8" userInfo={user} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{user.userName}</span>
|
<span className="truncate font-semibold">{user?.userName}</span>
|
||||||
<span className="truncate text-xs">{user.email}</span>
|
<span className="truncate text-xs">{user?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
@@ -77,7 +79,7 @@ export function NavUser({
|
|||||||
await logout()
|
await logout()
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
} className="w-full flex flex-row space-x-2 items-center"><LogOut className="h-4 w-4" /><p>登出</p></div>
|
} className="w-full flex flex-row space-x-2 items-center"><LogOut className="h-4 w-4" /><p>{t('common.logout')}</p></div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
@@ -4,7 +4,10 @@ import { TbDeviceHeartMonitor, TbEngine, TbEngineOff, TbServer2, TbServerBolt, T
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { $platformInfo } from '@/store/user'
|
import { $platformInfo } from '@/store/user'
|
||||||
import { getPlatformInfo } from '@/api/platform'
|
import { getPlatformInfo } from '@/api/platform'
|
||||||
export const PlatformInfo = () => {
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export default function PlatformInfo() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const platformInfo = useQuery({
|
const platformInfo = useQuery({
|
||||||
queryKey: ['platformInfo'],
|
queryKey: ['platformInfo'],
|
||||||
queryFn: getPlatformInfo,
|
queryFn: getPlatformInfo,
|
||||||
@@ -17,73 +20,73 @@ export const PlatformInfo = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<h3 className="tracking-tight text-sm font-medium">已配置服务端数</h3>
|
<h3 className="tracking-tight text-sm font-medium">{t('platform.configuredServers')}</h3>
|
||||||
<TbServerBolt className="mt-1" />
|
<TbServerBolt className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{platformInfo.data?.configuredServerCount} 个</div>
|
<div className="text-2xl font-bold">{platformInfo.data?.configuredServerCount} {t('platform.unit')}</div>
|
||||||
<p className="text-xs text-muted-foreground">请前往左侧🫲菜单修改</p>
|
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<h3 className="tracking-tight text-sm font-medium">已配置客户端数</h3>
|
<h3 className="tracking-tight text-sm font-medium">{t('platform.configuredClients')}</h3>
|
||||||
<TbEngine className="mt-1" />
|
<TbEngine className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{platformInfo.data?.configuredClientCount} 个</div>
|
<div className="text-2xl font-bold">{platformInfo.data?.configuredClientCount} {t('platform.unit')}</div>
|
||||||
<p className="text-xs text-muted-foreground">请前往左侧🫲菜单修改</p>
|
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<h3 className="tracking-tight text-sm font-medium">未配置服务端数</h3>
|
<h3 className="tracking-tight text-sm font-medium">{t('platform.unconfiguredServers')}</h3>
|
||||||
<TbServerOff className="mt-1" />
|
<TbServerOff className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredServerCount} 个</div>
|
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredServerCount} {t('platform.unit')}</div>
|
||||||
<p className="text-xs text-muted-foreground">请前往左侧🫲菜单修改</p>
|
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<h3 className="tracking-tight text-sm font-medium">未配置客户端数</h3>
|
<h3 className="tracking-tight text-sm font-medium">{t('platform.unconfiguredClients')}</h3>
|
||||||
<TbEngineOff className="mt-1" />
|
<TbEngineOff className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredClientCount} 个</div>
|
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredClientCount} {t('platform.unit')}</div>
|
||||||
<p className="text-xs text-muted-foreground">请前往左侧🫲菜单修改</p>
|
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<h3 className="tracking-tight text-sm font-medium">服务端总数</h3>
|
<h3 className="tracking-tight text-sm font-medium">{t('platform.totalServers')}</h3>
|
||||||
<TbServer2 className="mt-1" />
|
<TbServer2 className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{platformInfo.data?.totalServerCount} 个</div>
|
<div className="text-2xl font-bold">{platformInfo.data?.totalServerCount} {t('platform.unit')}</div>
|
||||||
<p className="text-xs text-muted-foreground">请前往左侧🫲菜单修改</p>
|
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<h3 className="tracking-tight text-sm font-medium">客户端总数</h3>
|
<h3 className="tracking-tight text-sm font-medium">{t('platform.totalClients')}</h3>
|
||||||
<TbDeviceHeartMonitor className="mt-1" />
|
<TbDeviceHeartMonitor className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{platformInfo.data?.totalClientCount} 个</div>
|
<div className="text-2xl font-bold">{platformInfo.data?.totalClientCount} {t('platform.unit')}</div>
|
||||||
<p className="text-xs text-muted-foreground">请前往左侧🫲菜单修改</p>
|
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -2,7 +2,7 @@ import { ZodEmailSchema, ZodStringSchema } from '@/lib/consts'
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'
|
import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from '@/components/ui/form'
|
||||||
import { Input } from './ui/input'
|
import { Input } from './ui/input'
|
||||||
import { register } from '@/api/auth'
|
import { register } from '@/api/auth'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
@@ -15,6 +15,7 @@ import { useToast } from './ui/use-toast'
|
|||||||
import { RespCode } from '@/lib/pb/common'
|
import { RespCode } from '@/lib/pb/common'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Toast } from './ui/toast'
|
import { Toast } from './ui/toast'
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export const RegisterSchema = z.object({
|
export const RegisterSchema = z.object({
|
||||||
username: ZodStringSchema,
|
username: ZodStringSchema,
|
||||||
@@ -22,7 +23,8 @@ export const RegisterSchema = z.object({
|
|||||||
email: ZodEmailSchema,
|
email: ZodEmailSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const RegisterComponent = () => {
|
export function RegisterComponent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const form = useForm<z.infer<typeof RegisterSchema>>({
|
const form = useForm<z.infer<typeof RegisterSchema>>({
|
||||||
resolver: zodResolver(RegisterSchema),
|
resolver: zodResolver(RegisterSchema),
|
||||||
})
|
})
|
||||||
@@ -35,20 +37,20 @@ export const RegisterComponent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
|
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
|
||||||
toast({ title: '注册中,请稍候' })
|
toast({ title: t('auth.registering') })
|
||||||
try {
|
try {
|
||||||
const res = await register({ ...values })
|
const res = await register({ ...values })
|
||||||
if (res.status?.code === RespCode.SUCCESS) {
|
if (res.status?.code === RespCode.SUCCESS) {
|
||||||
toast({ title: '注册成功,正在跳转到登录' })
|
toast({ title: t('auth.registerSuccess') })
|
||||||
setRegisterAlert(false)
|
setRegisterAlert(false)
|
||||||
await sleep(3000)
|
await sleep(3000)
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
} else {
|
} else {
|
||||||
toast({ title: '注册失败' })
|
toast({ title: t('auth.registerFailed') })
|
||||||
setRegisterAlert(true)
|
setRegisterAlert(true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '注册失败' })
|
toast({ title: t('auth.registerFailed') })
|
||||||
console.log('register error', e)
|
console.log('register error', e)
|
||||||
setRegisterAlert(true)
|
setRegisterAlert(true)
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,7 @@ export const RegisterComponent = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="text" placeholder="用户名" {...field} />
|
<Input type="text" placeholder={t('auth.usernamePlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -76,7 +78,7 @@ export const RegisterComponent = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="email" placeholder="邮箱地址" {...field} />
|
<Input type="email" placeholder={t('auth.emailPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -88,7 +90,7 @@ export const RegisterComponent = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="密码" {...field} />
|
<Input type="password" placeholder={t('auth.passwordPlaceholder')} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -97,11 +99,13 @@ export const RegisterComponent = () => {
|
|||||||
{registerAlert && (
|
{registerAlert && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||||
<AlertTitle>错误</AlertTitle>
|
<AlertTitle>{t('auth.error')}</AlertTitle>
|
||||||
<AlertDescription>注册失败,请重试</AlertDescription>
|
<AlertDescription>{t('auth.registerFailed')}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Button type="submit">注册</Button>
|
<Button type="submit">
|
||||||
|
{t('common.register')}
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -11,11 +11,13 @@ import { ProxySelector } from '../base/proxy-selector'
|
|||||||
import { ProxyInfo } from '@/lib/pb/common'
|
import { ProxyInfo } from '@/lib/pb/common'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '../ui/button'
|
||||||
import { CheckCircle2, CircleX, RefreshCcw } from "lucide-react"
|
import { CheckCircle2, CircleX, RefreshCcw } from "lucide-react"
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export interface ClientStatsCardProps {
|
export interface ClientStatsCardProps {
|
||||||
clientID?: string
|
clientID?: string
|
||||||
}
|
}
|
||||||
export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defaultClientID }: ClientStatsCardProps = {}) => {
|
export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defaultClientID }: ClientStatsCardProps = {}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [clientID, setClientID] = useState<string | undefined>()
|
const [clientID, setClientID] = useState<string | undefined>()
|
||||||
const [proxyName, setProxyName] = useState<string | undefined>()
|
const [proxyName, setProxyName] = useState<string | undefined>()
|
||||||
const [status, setStatus] = useState<"loading" | "success" | "error" | undefined>()
|
const [status, setStatus] = useState<"loading" | "success" | "error" | undefined>()
|
||||||
@@ -75,20 +77,20 @@ export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defa
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>客户端统计</CardTitle>
|
<CardTitle>{t('client.stats.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div>
|
<div>
|
||||||
按照客户端名称对所有隧道的流量使用统计
|
{t('client.stats.description')}
|
||||||
</div>
|
</div>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4 flex flex-col flex-1'>
|
<CardContent className='space-y-4 flex flex-col flex-1'>
|
||||||
<Label>客户端</Label>
|
<Label>{t('client.stats.label')}</Label>
|
||||||
<ClientSelector clientID={clientID} setClientID={handleClientChange} onOpenChange={() => {
|
<ClientSelector clientID={clientID} setClientID={handleClientChange} onOpenChange={() => {
|
||||||
refetchClientStats()
|
refetchClientStats()
|
||||||
setProxyName(undefined)
|
setProxyName(undefined)
|
||||||
}} />
|
}} />
|
||||||
<Label>隧道名称</Label>
|
<Label>{t('proxy.stats.label')}</Label>
|
||||||
<ProxySelector
|
<ProxySelector
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
proxyNames={Array.from(new Set(clientStatsList?.proxyInfos.map((proxyInfo) => proxyInfo.name).filter((value) => value !== undefined))) || []}
|
proxyNames={Array.from(new Set(clientStatsList?.proxyInfos.map((proxyInfo) => proxyInfo.name).filter((value) => value !== undefined))) || []}
|
||||||
@@ -96,7 +98,8 @@ export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defa
|
|||||||
setProxyname={setProxyName} />
|
setProxyname={setProxyName} />
|
||||||
<div className="w-full grid gap-4 grid-cols-1">
|
<div className="w-full grid gap-4 grid-cols-1">
|
||||||
{clientStatsList && clientStatsList.proxyInfos.length > 0 &&
|
{clientStatsList && clientStatsList.proxyInfos.length > 0 &&
|
||||||
ProxyStatusCard(mergeProxyInfos(clientStatsList.proxyInfos).find((proxyInfo) => proxyInfo.name === proxyName))}
|
<ProxyStatusCard
|
||||||
|
proxyInfo={mergeProxyInfos(clientStatsList.proxyInfos).find((proxyInfo) => proxyInfo.name === proxyName)} />}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
@@ -118,29 +121,35 @@ export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defa
|
|||||||
{status === "loading" && <RefreshCcw className="w-4 h-4 animate-spin" />}
|
{status === "loading" && <RefreshCcw className="w-4 h-4 animate-spin" />}
|
||||||
{status === "success" && <CheckCircle2 className="w-4 h-4" />}
|
{status === "success" && <CheckCircle2 className="w-4 h-4" />}
|
||||||
{status === "error" && <CircleX className="w-4 h-4" />}
|
{status === "error" && <CircleX className="w-4 h-4" />}
|
||||||
<p>刷新数据</p></Button>
|
<p>{t('refresh.data')}</p></Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProxyStatusCard = (proxyInfo: ProxyInfo | undefined) => {
|
const ProxyStatusCard: React.FC<{ proxyInfo: ProxyInfo | undefined }> = ({ proxyInfo }) => {
|
||||||
return (<>{proxyInfo &&
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!proxyInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={proxyInfo.name} className="flex flex-col space-y-4">
|
<div key={proxyInfo.name} className="flex flex-col space-y-4">
|
||||||
<Label>{`隧道 ${proxyInfo.name} 流量使用`}</Label>
|
<Label>{t('proxy.stats.tunnel_traffic', { name: proxyInfo.name })}</Label>
|
||||||
<ProxyTrafficOverview proxyInfo={proxyInfo} />
|
<ProxyTrafficOverview proxyInfo={proxyInfo} />
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
|
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||||
<ProxyTrafficPieChart
|
<ProxyTrafficPieChart
|
||||||
title='今日流量统计'
|
title={t('proxy.stats.today_traffic_title')}
|
||||||
chartLabel='今日总流量'
|
chartLabel={t('proxy.stats.today_traffic_total')}
|
||||||
trafficIn={proxyInfo.todayTrafficIn || BigInt(0)}
|
trafficIn={proxyInfo.todayTrafficIn || BigInt(0)}
|
||||||
trafficOut={proxyInfo.todayTrafficOut || BigInt(0)} />
|
trafficOut={proxyInfo.todayTrafficOut || BigInt(0)} />
|
||||||
<ProxyTrafficPieChart
|
<ProxyTrafficPieChart
|
||||||
title='历史流量统计'
|
title={t('proxy.stats.history_traffic_title')}
|
||||||
chartLabel='历史总流量'
|
chartLabel={t('proxy.stats.history_traffic_total')}
|
||||||
trafficIn={proxyInfo.historyTrafficIn || BigInt(0)}
|
trafficIn={proxyInfo.historyTrafficIn || BigInt(0)}
|
||||||
trafficOut={proxyInfo.historyTrafficOut || BigInt(0)} />
|
trafficOut={proxyInfo.historyTrafficOut || BigInt(0)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}</>)
|
);
|
||||||
}
|
}
|
@@ -16,6 +16,7 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { CaretSortIcon, PlusIcon } from "@radix-ui/react-icons"
|
import { CaretSortIcon, PlusIcon } from "@radix-ui/react-icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
export function TeamSwitcher({
|
export function TeamSwitcher({
|
||||||
teams,
|
teams,
|
||||||
@@ -29,6 +30,7 @@ export function TeamSwitcher({
|
|||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -58,7 +60,7 @@ export function TeamSwitcher({
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
租户
|
{t('team.title')}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{teams.map((team, index) => (
|
{teams.map((team, index) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -85,7 +87,7 @@ export function TeamSwitcher({
|
|||||||
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-muted-foreground">添加租户</div>
|
<div className="font-medium text-muted-foreground">{t('team.add')}</div>
|
||||||
</DropdownMenuItem> */}
|
</DropdownMenuItem> */}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
58
www/config/nav.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
SquareTerminal,
|
||||||
|
ServerCogIcon,
|
||||||
|
ServerIcon,
|
||||||
|
MonitorSmartphoneIcon,
|
||||||
|
MonitorCogIcon,
|
||||||
|
ChartNetworkIcon,
|
||||||
|
Scroll,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { TbBuildingTunnel } from "react-icons/tb"
|
||||||
|
|
||||||
|
export const teams = [
|
||||||
|
{
|
||||||
|
name: "Frp-Panel",
|
||||||
|
logo: TbBuildingTunnel,
|
||||||
|
plan: "Community Edition",
|
||||||
|
url: "/",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const getNavItems = (t: any) => [
|
||||||
|
{
|
||||||
|
title: t('nav.clients'),
|
||||||
|
url: "/clients",
|
||||||
|
icon: MonitorSmartphoneIcon,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.servers'),
|
||||||
|
url: "/servers",
|
||||||
|
icon: ServerIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.editClient'),
|
||||||
|
url: "/clientedit",
|
||||||
|
icon: MonitorCogIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.editServer'),
|
||||||
|
url: "/serveredit",
|
||||||
|
icon: ServerCogIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.trafficStats'),
|
||||||
|
url: "/clientstats",
|
||||||
|
icon: ChartNetworkIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.realTimeLog'),
|
||||||
|
url: "/streamlog",
|
||||||
|
icon: Scroll,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('nav.console'),
|
||||||
|
url: "/console",
|
||||||
|
icon: SquareTerminal,
|
||||||
|
},
|
||||||
|
]
|
39
www/i18n/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import { $language } from '@/store/user';
|
||||||
|
import enTranslation from './locales/en.json';
|
||||||
|
import zhTranslation from './locales/zh.json';
|
||||||
|
|
||||||
|
const savedLanguage = $language.get();
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: enTranslation,
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
translation: zhTranslation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lng: savedLanguage || 'zh',
|
||||||
|
fallbackLng: 'zh',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听语言变化并同步到 i18n
|
||||||
|
$language.subscribe((newLanguage) => {
|
||||||
|
if (newLanguage && i18n.language !== newLanguage) {
|
||||||
|
i18n.changeLanguage(newLanguage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步初始语言
|
||||||
|
if (savedLanguage) {
|
||||||
|
i18n.changeLanguage(savedLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n;
|
452
www/i18n/locales/en.json
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "FRP-Panel",
|
||||||
|
"subtitle": "FRP Tunnel Panel",
|
||||||
|
"description": "A multi node frp webui for [FRP] server and client management, which makes this project a [Cloudflare Tunnel] or [Tailscale Funnel] open source alternative",
|
||||||
|
"github": {
|
||||||
|
"navigate": "navigate to:",
|
||||||
|
"repo": "VaalaCat/frp-panel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"clients": "Clients",
|
||||||
|
"servers": "Servers",
|
||||||
|
"editClient": "Edit Tunnel",
|
||||||
|
"editServer": "Edit Server",
|
||||||
|
"trafficStats": "Traffic Stats",
|
||||||
|
"realTimeLog": "Real-time Log",
|
||||||
|
"console": "Console",
|
||||||
|
"user": {
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"traffic": {
|
||||||
|
"today": {
|
||||||
|
"inbound": "Today's Inbound",
|
||||||
|
"outbound": "Today's Outbound"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"inbound": "Historical Inbound",
|
||||||
|
"outbound": "Historical Outbound"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Traffic Statistics",
|
||||||
|
"description": "View real-time traffic statistics",
|
||||||
|
"label": "Traffic Stats"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "Traffic Details",
|
||||||
|
"inbound": "Inbound",
|
||||||
|
"outbound": "Outbound",
|
||||||
|
"today": "Today",
|
||||||
|
"history": "History",
|
||||||
|
"pie": {
|
||||||
|
"inbound": "Inbound",
|
||||||
|
"outbound": "Outbound",
|
||||||
|
"total": "Total Traffic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"logout": "Logout",
|
||||||
|
"connect": "Connect",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"clear": "Clear",
|
||||||
|
"clientType": "Client Type",
|
||||||
|
"streamlog": "Stream Log",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"warning": "Warning",
|
||||||
|
"info": "Information",
|
||||||
|
"download": "Click here to download",
|
||||||
|
"copy": "Copy",
|
||||||
|
"download": "Download",
|
||||||
|
"submit": "Submit",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"newWindow": "New Window"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"loginTitle": "Login",
|
||||||
|
"registerTitle": "Register",
|
||||||
|
"inputCredentials": "Enter your credentials",
|
||||||
|
"email": {
|
||||||
|
"required": "Cannot be empty",
|
||||||
|
"invalid": "Please check your email address"
|
||||||
|
},
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm Password",
|
||||||
|
"usernamePlaceholder": "Username",
|
||||||
|
"emailPlaceholder": "Email address",
|
||||||
|
"passwordPlaceholder": "••••••••",
|
||||||
|
"error": "Error",
|
||||||
|
"loggingIn": "Logging in, please wait",
|
||||||
|
"loginSuccess": "Login successful, redirecting to home page",
|
||||||
|
"loginFailed": "Login failed, please try again",
|
||||||
|
"registering": "Registering, please wait",
|
||||||
|
"registerSuccess": "Registration successful, redirecting to login",
|
||||||
|
"registerFailed": "Registration failed, please try again",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"haveAccount": "Already have an account?",
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "Required field",
|
||||||
|
"portRange": {
|
||||||
|
"min": "Port cannot be less than 1",
|
||||||
|
"max": "Port cannot be greater than 65535"
|
||||||
|
},
|
||||||
|
"ipAddress": "Please enter a valid IP address"
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"configuredServers": "Configured Servers",
|
||||||
|
"configuredClients": "Configured Clients",
|
||||||
|
"unconfiguredServers": "Unconfigured Servers",
|
||||||
|
"unconfiguredClients": "Unconfigured Clients",
|
||||||
|
"totalServers": "Total Servers",
|
||||||
|
"totalClients": "Total Clients",
|
||||||
|
"unit": "",
|
||||||
|
"menuHint": "Please modify in the left menu"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"configuration": "Server Configuration",
|
||||||
|
"warning": {
|
||||||
|
"title": "Warning⚠️: The server will exit after modifying the configuration file",
|
||||||
|
"dockerHint": "If you are using a docker container with --restart=unless-stopped or --restart=always in the startup command, there's no need to worry.",
|
||||||
|
"systemdHint": "If you are using systemd installation, there's no need to worry."
|
||||||
|
},
|
||||||
|
"selectHint": "Select a server to manage Frps service",
|
||||||
|
"advancedMode": {
|
||||||
|
"title": "Advanced Mode",
|
||||||
|
"description": "Edit server raw configuration file"
|
||||||
|
},
|
||||||
|
"serverLabel": "Server",
|
||||||
|
"id": "ID (Click for install command)",
|
||||||
|
"status": "Configuration Status",
|
||||||
|
"info": "Running Info/Version",
|
||||||
|
"secret": "Secret (Click for start command)",
|
||||||
|
"ip": "IP Address",
|
||||||
|
"actions": "Actions",
|
||||||
|
"status_configured": "Configured",
|
||||||
|
"status_unconfigured": "Unconfigured",
|
||||||
|
"status_online": "Online",
|
||||||
|
"status_offline": "Offline",
|
||||||
|
"status_error": "Error",
|
||||||
|
"status_unknown": "Unknown",
|
||||||
|
"status_pause": "Paused",
|
||||||
|
"install": {
|
||||||
|
"title": "Install Command",
|
||||||
|
"description": "Please select your operating system and copy the corresponding installation command",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"copy": "Copy Command"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"title": "Start Command",
|
||||||
|
"description": "Copy and run the following command to start frps",
|
||||||
|
"copy": "Copy Command"
|
||||||
|
},
|
||||||
|
"actions_menu": {
|
||||||
|
"title": "Server Actions",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"start": "Start",
|
||||||
|
"stop": "Stop",
|
||||||
|
"detail": "Details",
|
||||||
|
"open_menu": "Open Menu",
|
||||||
|
"copy_command": "Copy Start Command",
|
||||||
|
"copy_success": "Copy successful, if copy failed, please click ID field to copy manually",
|
||||||
|
"copy_failed": "Failed to get platform info, if copy failed, please click ID field to copy manually",
|
||||||
|
"edit_config": "Edit Configuration",
|
||||||
|
"download_config": "Download Configuration",
|
||||||
|
"download_failed": "Failed to get platform info",
|
||||||
|
"realtime_log": "Real-time Log",
|
||||||
|
"remote_terminal": "Remote Terminal"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete Server?",
|
||||||
|
"description": "This action cannot be undone. Are you sure you want to permanently delete this server from our servers?",
|
||||||
|
"warning": "After deletion, running servers will not be able to connect again with existing parameters. If you need to delete server connections, you can choose to pause the server",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"success": "Delete successful",
|
||||||
|
"failed": "Delete failed"
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"stop_success": "Stop successful",
|
||||||
|
"stop_failed": "Stop failed",
|
||||||
|
"start_success": "Start successful",
|
||||||
|
"start_failed": "Start failed",
|
||||||
|
"update_title": "Update Server Status",
|
||||||
|
"update_success": "Update successful",
|
||||||
|
"update_failed": "Update failed"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"comment_title": "Node {{id}} Comment",
|
||||||
|
"comment_hint": "You can modify the comment in advanced mode!",
|
||||||
|
"comment_empty": "Empty",
|
||||||
|
"public_host": "Public Host",
|
||||||
|
"bind_port": "FRPs Listen Port",
|
||||||
|
"bind_addr": "FRPs Listen Address",
|
||||||
|
"proxy_bind_addr": "Proxy Listen Address",
|
||||||
|
"vhost_http_port": "HTTP Listen Port",
|
||||||
|
"subdomain_host": "Subdomain Host"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"comment": "Node {{id}} Comment",
|
||||||
|
"comment_placeholder": "Comment",
|
||||||
|
"config_title": "Node {{id}} Configuration File `frps.json` Content",
|
||||||
|
"config_description": "Only configure port and IP fields, authentication information will be completed by the system",
|
||||||
|
"config_placeholder": "Configuration File Content"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"button": "Create",
|
||||||
|
"title": "Create New Server",
|
||||||
|
"description": "Create a new server for providing service. Server ID must be unique",
|
||||||
|
"id": "Server ID",
|
||||||
|
"ip": "IP Address/Domain",
|
||||||
|
"submit": "Create",
|
||||||
|
"submitting": "Creating server...",
|
||||||
|
"success": "Server created successfully",
|
||||||
|
"error": "Failed to create server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"id": "ID (Click for Install Command)",
|
||||||
|
"status": "Configuration Status",
|
||||||
|
"info": "Runtime Info/Version",
|
||||||
|
"secret": "Secret Key (Click for Start Command)",
|
||||||
|
"actions": "Actions",
|
||||||
|
"status_configured": "Configured",
|
||||||
|
"status_unconfigured": "Not Configured",
|
||||||
|
"status_online": "Online",
|
||||||
|
"status_offline": "Offline",
|
||||||
|
"status_error": "Error",
|
||||||
|
"status_pause": "Paused",
|
||||||
|
"status_unknown": "Unknown",
|
||||||
|
"stats": {
|
||||||
|
"title": "Client Statistics",
|
||||||
|
"description": "View client traffic statistics",
|
||||||
|
"label": "Client"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "Installation Command",
|
||||||
|
"description": "Select your operating system and copy the installation command",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"copy": "Copy Command"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"title": "Start Command",
|
||||||
|
"description": "Copy and run the following command to start frpc",
|
||||||
|
"copy": "Copy Command"
|
||||||
|
},
|
||||||
|
"actions_menu": {
|
||||||
|
"title": "Client Actions",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"start": "Start",
|
||||||
|
"stop": "Stop",
|
||||||
|
"detail": "Details",
|
||||||
|
"open_menu": "Open Menu",
|
||||||
|
"copy_command": "Copy Start Command",
|
||||||
|
"copy_success": "Command copied successfully. If copying fails, please click the ID field to copy manually",
|
||||||
|
"copy_failed": "Failed to get platform info. If copying fails, please click the ID field to copy manually",
|
||||||
|
"edit_config": "Edit Configuration",
|
||||||
|
"download_config": "Download Configuration",
|
||||||
|
"download_failed": "Failed to get platform info",
|
||||||
|
"realtime_log": "Real-time Log",
|
||||||
|
"remote_terminal": "Remote Terminal",
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Resume"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete Client",
|
||||||
|
"description": "This action cannot be undone. Are you sure you want to permanently delete this client from our servers?",
|
||||||
|
"warning": "After deletion, running clients will not be able to connect again with existing parameters. If you need to remove client external connections, you can choose to pause the client instead.",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"success": "Delete successful",
|
||||||
|
"failed": "Delete failed"
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"stop_success": "Stop successful",
|
||||||
|
"stop_failed": "Stop failed",
|
||||||
|
"start_success": "Start successful",
|
||||||
|
"start_failed": "Start failed",
|
||||||
|
"update_success": "Update successful",
|
||||||
|
"update_failed": "Update failed"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"comment_title": "Node {{id}} Comment",
|
||||||
|
"comment_placeholder": "Comment",
|
||||||
|
"config_title": "Client {{id}} Configuration File `frpc.json` Content",
|
||||||
|
"config_description": "Only configure proxies and visitors fields, authentication and server connection information will be completed by the system",
|
||||||
|
"config_placeholder": "Configuration File Content"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"button": "Create",
|
||||||
|
"title": "Create New Client",
|
||||||
|
"description": "Create a new client for connection. Client ID must be unique",
|
||||||
|
"id": "Client ID",
|
||||||
|
"submit": "Create",
|
||||||
|
"submitting": "Creating client...",
|
||||||
|
"success": "Client created successfully",
|
||||||
|
"error": "Failed to create client"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Client Information",
|
||||||
|
"version": "Version",
|
||||||
|
"buildDate": "Build Date",
|
||||||
|
"goVersion": "Go Version",
|
||||||
|
"platform": "Platform",
|
||||||
|
"address": "Client Address",
|
||||||
|
"connectTime": "Connected Since"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"stats": {
|
||||||
|
"label": "Tunnel Name",
|
||||||
|
"tunnel_traffic": "Tunnel Traffic: {{name}}",
|
||||||
|
"today_traffic_title": "Today's Traffic",
|
||||||
|
"today_traffic_total": "Today's Total",
|
||||||
|
"history_traffic_title": "Historical Traffic",
|
||||||
|
"history_traffic_total": "Historical Total"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"add": "Add Tunnel",
|
||||||
|
"name": "Name",
|
||||||
|
"protocol": "Protocol",
|
||||||
|
"type": "Type",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"config": "Tunnel Configuration",
|
||||||
|
"expand": "Click to expand {{count}} tunnels",
|
||||||
|
"tunnel_name": "Tunnel Name",
|
||||||
|
"delete": "Delete",
|
||||||
|
"type_label": "Type: [{{type}}]",
|
||||||
|
"access_method": "Access Method",
|
||||||
|
"local_port": "Local Port",
|
||||||
|
"remote_port": "Remote Port",
|
||||||
|
"local_ip": "Local IP",
|
||||||
|
"subdomain": "Subdomain",
|
||||||
|
"secret_key": "Secret Key",
|
||||||
|
"save": "Save",
|
||||||
|
"save_success": "Save successful",
|
||||||
|
"save_error": "Save failed",
|
||||||
|
"save_changes": "Save Changes",
|
||||||
|
"submit": "Submit Changes"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"http": "HTTP",
|
||||||
|
"tcp": "TCP",
|
||||||
|
"udp": "UDP",
|
||||||
|
"stcp": "STCP"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"update": "Update Tunnel Status",
|
||||||
|
"success": "Update successful",
|
||||||
|
"error": "Update failed",
|
||||||
|
"create": "Create Tunnel Status",
|
||||||
|
"name_exists": "Name already exists"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frpc_form": {
|
||||||
|
"add": "Add Client",
|
||||||
|
"name": "Name",
|
||||||
|
"protocol": "Protocol",
|
||||||
|
"type": "Type",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"config": "Client Configuration",
|
||||||
|
"expand": "Click to expand {{count}} clients",
|
||||||
|
"client_name": "Client Name",
|
||||||
|
"delete": "Delete",
|
||||||
|
"type_label": "Type: [{{type}}]",
|
||||||
|
"access_method": "Access Method",
|
||||||
|
"local_port": "Local Port",
|
||||||
|
"remote_port": "Remote Port",
|
||||||
|
"local_ip": "Local IP",
|
||||||
|
"subdomain": "Subdomain",
|
||||||
|
"secret_key": "Secret Key",
|
||||||
|
"save": "Save",
|
||||||
|
"save_success": "Save successful",
|
||||||
|
"save_error": "Save failed"
|
||||||
|
},
|
||||||
|
"frpc": {
|
||||||
|
"form": {
|
||||||
|
"title": "Edit Tunnel",
|
||||||
|
"description": {
|
||||||
|
"warning": "Warning⚠️: The selected 'Server' must be configured in advance!",
|
||||||
|
"instruction": "Select client and server to edit tunnel"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "Advanced Mode",
|
||||||
|
"description": "Edit client raw configuration file"
|
||||||
|
},
|
||||||
|
"server": "Server",
|
||||||
|
"client": "Client",
|
||||||
|
"comment": {
|
||||||
|
"title": "Node {{id}} Comment",
|
||||||
|
"hint": "You can modify the comment in advanced mode!",
|
||||||
|
"empty": "Nothing here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refresh": {
|
||||||
|
"data": "Refresh Data",
|
||||||
|
"data_zh": "刷新数据"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"title": "Teams",
|
||||||
|
"add": "Add Team"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"toggle": "Toggle Language",
|
||||||
|
"zh": "Chinese",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"client": {
|
||||||
|
"placeholder": "Client Name"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"placeholder": "Server Name"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"placeholder": "Please select...",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"notFound": "No results found"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"notFound": "No tunnel found",
|
||||||
|
"placeholder": "Tunnel name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"search": "Search",
|
||||||
|
"id": {
|
||||||
|
"placeholder": "Enter ID"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"filter": {
|
||||||
|
"placeholder": "Filter by {{column}}"
|
||||||
|
},
|
||||||
|
"noData": "No data",
|
||||||
|
"pagination": {
|
||||||
|
"rowsPerPage": "rows per page",
|
||||||
|
"page": "Page {{current}} of {{total}}",
|
||||||
|
"navigation": {
|
||||||
|
"first": "Go to first page",
|
||||||
|
"previous": "Go to previous page",
|
||||||
|
"next": "Go to next page",
|
||||||
|
"last": "Go to last page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
458
www/i18n/locales/zh.json
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "FRP-Panel",
|
||||||
|
"subtitle": "FRP 隧道面板",
|
||||||
|
"description": "一个多节点的 FRP WebUI,用于 [FRP] 服务端和客户端管理,是 [Cloudflare Tunnel] 和 [Tailscale Funnel] 的开源替代方案",
|
||||||
|
"github": {
|
||||||
|
"navigate": "项目地址:",
|
||||||
|
"repo": "VaalaCat/frp-panel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"clients": "客户端",
|
||||||
|
"servers": "服务端",
|
||||||
|
"editClient": "编辑隧道",
|
||||||
|
"editServer": "编辑服务端",
|
||||||
|
"trafficStats": "流量统计",
|
||||||
|
"realTimeLog": "实时日志",
|
||||||
|
"console": "控制台",
|
||||||
|
"user": {
|
||||||
|
"profile": "个人资料",
|
||||||
|
"settings": "设置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"traffic": {
|
||||||
|
"today": {
|
||||||
|
"inbound": "今日入站流量",
|
||||||
|
"outbound": "今日出站流量"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"inbound": "历史入站流量",
|
||||||
|
"outbound": "历史出站流量"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "流量统计",
|
||||||
|
"description": "查看实时流量统计信息",
|
||||||
|
"label": "流量统计"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "流量详情",
|
||||||
|
"inbound": "入站",
|
||||||
|
"outbound": "出站",
|
||||||
|
"today": "今日",
|
||||||
|
"history": "历史",
|
||||||
|
"pie": {
|
||||||
|
"inbound": "入站",
|
||||||
|
"outbound": "出站",
|
||||||
|
"total": "总流量"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"login": "登录",
|
||||||
|
"register": "注册",
|
||||||
|
"logout": "退出登录",
|
||||||
|
"connect": "连接",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"disconnect": "断开连接",
|
||||||
|
"submit": "提交",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"delete": "删除",
|
||||||
|
"edit": "编辑",
|
||||||
|
"newWindow": "新窗口",
|
||||||
|
"clientType": "客户端类型",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"error": "错误",
|
||||||
|
"success": "成功",
|
||||||
|
"warning": "警告",
|
||||||
|
"info": "信息",
|
||||||
|
"download": "点击这里下载",
|
||||||
|
"copy": "复制",
|
||||||
|
"clear": "清空",
|
||||||
|
"streamlog": "实时日志"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"loginTitle": "登录",
|
||||||
|
"registerTitle": "注册",
|
||||||
|
"inputCredentials": "请输入您的账号信息",
|
||||||
|
"email": {
|
||||||
|
"required": "不能为空",
|
||||||
|
"invalid": "请检查邮箱地址格式"
|
||||||
|
},
|
||||||
|
"password": "密码",
|
||||||
|
"confirmPassword": "确认密码",
|
||||||
|
"usernamePlaceholder": "用户名",
|
||||||
|
"emailPlaceholder": "邮箱地址",
|
||||||
|
"passwordPlaceholder": "••••••••",
|
||||||
|
"error": "错误",
|
||||||
|
"loggingIn": "登录中,请稍候",
|
||||||
|
"loginSuccess": "登录成功,正在跳转到首页",
|
||||||
|
"loginFailed": "登录失败,请重试",
|
||||||
|
"registering": "注册中,请稍候",
|
||||||
|
"registerSuccess": "注册成功,正在跳转到登录页",
|
||||||
|
"registerFailed": "注册失败,请重试",
|
||||||
|
"noAccount": "还没有账号?",
|
||||||
|
"haveAccount": "已有账号?",
|
||||||
|
"login": "登录",
|
||||||
|
"register": "注册"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "不能为空",
|
||||||
|
"portRange": {
|
||||||
|
"min": "端口号不能小于 1",
|
||||||
|
"max": "端口号不能大于 65535"
|
||||||
|
},
|
||||||
|
"ipAddress": "请输入正确的IP地址"
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"configuredServers": "已配置服务端数",
|
||||||
|
"configuredClients": "已配置客户端数",
|
||||||
|
"unconfiguredServers": "未配置服务端数",
|
||||||
|
"unconfiguredClients": "未配置客户端数",
|
||||||
|
"totalServers": "服务端总数",
|
||||||
|
"totalClients": "客户端总数",
|
||||||
|
"unit": "个",
|
||||||
|
"menuHint": "请前往左侧🫲菜单修改",
|
||||||
|
"refresh": {
|
||||||
|
"data": "刷新数据"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"id": "ID (点击查看安装命令)",
|
||||||
|
"status": "配置状态",
|
||||||
|
"info": "运行信息/版本",
|
||||||
|
"secret": "密钥 (点击查看启动命令)",
|
||||||
|
"ip": "IP地址",
|
||||||
|
"actions": "操作",
|
||||||
|
"status_configured": "已配置",
|
||||||
|
"status_unconfigured": "未配置",
|
||||||
|
"status_online": "在线",
|
||||||
|
"status_offline": "离线",
|
||||||
|
"status_error": "错误",
|
||||||
|
"status_unknown": "未知",
|
||||||
|
"status_pause": "已暂停",
|
||||||
|
"configuration": "服务器配置",
|
||||||
|
"warning": {
|
||||||
|
"title": "警告⚠️:修改配置文件后服务器将退出",
|
||||||
|
"dockerHint": "如果您使用 docker 容器且启动命令中包含 --restart=unless-stopped 或 --restart=always,则无需担心。",
|
||||||
|
"systemdHint": "如果您使用 systemd 安装,则无需担心。"
|
||||||
|
},
|
||||||
|
"selectHint": "选择一个服务器来管理 Frps 服务",
|
||||||
|
"advancedMode": {
|
||||||
|
"title": "高级模式",
|
||||||
|
"description": "编辑服务器原始配置文件"
|
||||||
|
},
|
||||||
|
"serverLabel": "服务器",
|
||||||
|
"install": {
|
||||||
|
"title": "安装命令",
|
||||||
|
"description": "请选择您的操作系统并复制相应的安装命令",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"copy": "复制命令"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"title": "启动命令",
|
||||||
|
"description": "复制并运行以下命令来启动 frps",
|
||||||
|
"copy": "复制命令"
|
||||||
|
},
|
||||||
|
"actions_menu": {
|
||||||
|
"title": "服务器操作",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"start": "启动",
|
||||||
|
"stop": "停止",
|
||||||
|
"detail": "详情",
|
||||||
|
"open_menu": "打开菜单",
|
||||||
|
"copy_command": "复制启动命令",
|
||||||
|
"copy_success": "复制成功,如果复制不成功,请点击ID字段手动复制",
|
||||||
|
"copy_failed": "获取平台信息失败,如果复制不成功,请点击ID字段手动复制",
|
||||||
|
"edit_config": "修改配置",
|
||||||
|
"download_config": "下载配置",
|
||||||
|
"download_failed": "获取平台信息失败",
|
||||||
|
"realtime_log": "实时日志",
|
||||||
|
"remote_terminal": "远程终端"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "确定删除该服务器?",
|
||||||
|
"description": "此操作无法撤消。您确定要永久从我们的服务器中删除该服务器?",
|
||||||
|
"warning": "删除后运行中的服务器将无法通过现有参数再次连接,如果您需要删除服务器对外的连接,可以选择暂停服务器",
|
||||||
|
"confirm": "确定",
|
||||||
|
"success": "删除成功",
|
||||||
|
"failed": "删除失败"
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"stop_success": "停止成功",
|
||||||
|
"stop_failed": "停止失败",
|
||||||
|
"start_success": "启动成功",
|
||||||
|
"start_failed": "启动失败",
|
||||||
|
"update_title": "修改服务端状态",
|
||||||
|
"update_success": "修改成功",
|
||||||
|
"update_failed": "修改失败"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"comment": "节点 {{id}} 的备注",
|
||||||
|
"comment_placeholder": "备注",
|
||||||
|
"config_title": "节点 {{id}} 配置文件`frps.json`内容",
|
||||||
|
"config_description": "只需要配置端口和IP等字段,认证信息会由系统补全",
|
||||||
|
"config_placeholder": "配置文件内容"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"comment_title": "节点 {{id}} 的备注",
|
||||||
|
"comment_hint": "可以到高级模式修改备注哦!",
|
||||||
|
"comment_empty": "空空如也",
|
||||||
|
"public_host": "公网地址",
|
||||||
|
"bind_port": "FRPs 监听端口",
|
||||||
|
"bind_addr": "FRPs 监听地址",
|
||||||
|
"proxy_bind_addr": "代理监听地址",
|
||||||
|
"vhost_http_port": "HTTP 监听端口",
|
||||||
|
"subdomain_host": "域名后缀"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"button": "新建",
|
||||||
|
"title": "新建服务端",
|
||||||
|
"description": "创建新的服务端用于提供服务,服务端ID必须唯一",
|
||||||
|
"id": "服务端ID",
|
||||||
|
"ip": "IP地址/域名",
|
||||||
|
"submit": "创建",
|
||||||
|
"submitting": "正在创建服务端...",
|
||||||
|
"success": "创建服务端成功",
|
||||||
|
"error": "创建服务端失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"id": "ID (点击查看安装命令)",
|
||||||
|
"status": "配置状态",
|
||||||
|
"info": "运行时信息/版本",
|
||||||
|
"secret": "密钥 (点击查看启动命令)",
|
||||||
|
"actions": "操作",
|
||||||
|
"status_configured": "已配置",
|
||||||
|
"status_unconfigured": "未配置",
|
||||||
|
"status_online": "在线",
|
||||||
|
"status_offline": "离线",
|
||||||
|
"status_error": "错误",
|
||||||
|
"status_pause": "已暂停",
|
||||||
|
"status_unknown": "未知",
|
||||||
|
"stats": {
|
||||||
|
"title": "客户端统计",
|
||||||
|
"description": "查看客户端流量统计信息",
|
||||||
|
"label": "客户端"
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"title": "安装命令",
|
||||||
|
"description": "请选择您的操作系统并复制相应的安装命令",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"copy": "复制命令"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"title": "启动命令",
|
||||||
|
"description": "复制并运行以下命令来启动 frpc",
|
||||||
|
"copy": "复制命令"
|
||||||
|
},
|
||||||
|
"actions_menu": {
|
||||||
|
"title": "客户端操作",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"start": "启动",
|
||||||
|
"stop": "停止",
|
||||||
|
"detail": "详情",
|
||||||
|
"open_menu": "打开菜单",
|
||||||
|
"copy_command": "复制启动命令",
|
||||||
|
"copy_success": "命令复制成功,如果复制失败,请点击ID字段手动复制",
|
||||||
|
"copy_failed": "获取平台信息失败,如果复制不成功,请点击ID字段手动复制",
|
||||||
|
"edit_config": "修改配置",
|
||||||
|
"download_config": "下载配置",
|
||||||
|
"download_failed": "获取平台信息失败",
|
||||||
|
"realtime_log": "实时日志",
|
||||||
|
"remote_terminal": "远程终端",
|
||||||
|
"pause": "暂停",
|
||||||
|
"resume": "启动"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "确定删除该客户端?",
|
||||||
|
"description": "此操作无法撤消。您确定要永久从我们的服务器中删除该客户端?",
|
||||||
|
"warning": "删除后运行中的客户端将无法通过现有参数再次连接,如果您需要删除客户端对外的连接,可以选择暂停客户端",
|
||||||
|
"confirm": "确定",
|
||||||
|
"success": "删除成功",
|
||||||
|
"failed": "删除失败"
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"stop_success": "停止成功",
|
||||||
|
"stop_failed": "停止失败",
|
||||||
|
"start_success": "启动成功",
|
||||||
|
"start_failed": "启动失败",
|
||||||
|
"update_success": "更新成功",
|
||||||
|
"update_failed": "更新失败"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"comment_title": "节点 {{id}} 的备注",
|
||||||
|
"comment_placeholder": "备注",
|
||||||
|
"config_title": "客户端 {{id}} 配置文件`frpc.json`内容",
|
||||||
|
"config_description": "只需要配置proxies和visitors字段,认证信息和服务器连接信息会由系统补全",
|
||||||
|
"config_placeholder": "配置文件内容"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"button": "新建",
|
||||||
|
"title": "新建客户端",
|
||||||
|
"description": "创建新的客户端用于连接,客户端ID必须唯一",
|
||||||
|
"id": "客户端ID",
|
||||||
|
"submit": "创建",
|
||||||
|
"submitting": "正在创建客户端...",
|
||||||
|
"success": "创建客户端成功",
|
||||||
|
"error": "创建客户端失败"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "客户端信息",
|
||||||
|
"version": "版本",
|
||||||
|
"buildDate": "编译时间",
|
||||||
|
"goVersion": "Go版本",
|
||||||
|
"platform": "客户端平台",
|
||||||
|
"address": "客户端地址",
|
||||||
|
"connectTime": "连接时间"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"stats": {
|
||||||
|
"label": "隧道名称",
|
||||||
|
"tunnel_traffic": "隧道流量:{{name}}",
|
||||||
|
"today_traffic_title": "今日流量",
|
||||||
|
"today_traffic_total": "今日总计",
|
||||||
|
"history_traffic_title": "历史流量",
|
||||||
|
"history_traffic_total": "历史总计"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"add": "新增隧道",
|
||||||
|
"name": "名称",
|
||||||
|
"protocol": "协议",
|
||||||
|
"type": "类型",
|
||||||
|
"confirm": "确定",
|
||||||
|
"config": "隧道配置",
|
||||||
|
"expand": "点击展开{{count}}条隧道",
|
||||||
|
"tunnel_name": "隧道名称",
|
||||||
|
"delete": "删除",
|
||||||
|
"type_label": "类型:「{{type}}」",
|
||||||
|
"access_method": "访问方式",
|
||||||
|
"local_port": "本地端口",
|
||||||
|
"remote_port": "远程端口",
|
||||||
|
"local_ip": "本地IP",
|
||||||
|
"subdomain": "子域名",
|
||||||
|
"secret_key": "密钥",
|
||||||
|
"save": "保存",
|
||||||
|
"save_success": "保存成功",
|
||||||
|
"save_error": "保存失败",
|
||||||
|
"save_changes": "暂存修改",
|
||||||
|
"submit": "提交变更",
|
||||||
|
"default_port": "默认端口",
|
||||||
|
"port_placeholder": "请输入端口号 (1-65535)",
|
||||||
|
"ip_placeholder": "请输入IP地址 (例如: 127.0.0.1)",
|
||||||
|
"subdomain_placeholder": "请输入子域名",
|
||||||
|
"secret_placeholder": "请输入密钥"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"http": "HTTP",
|
||||||
|
"tcp": "TCP",
|
||||||
|
"udp": "UDP",
|
||||||
|
"stcp": "STCP"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"update": "更新隧道状态",
|
||||||
|
"success": "更新成功",
|
||||||
|
"error": "更新失败",
|
||||||
|
"create": "创建隧道状态",
|
||||||
|
"name_exists": "名称重复"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frpc_form": {
|
||||||
|
"add": "新增隧道",
|
||||||
|
"name": "名称",
|
||||||
|
"protocol": "协议",
|
||||||
|
"type": "类型",
|
||||||
|
"confirm": "确定",
|
||||||
|
"config": "隧道配置",
|
||||||
|
"expand": "点击展开{{count}}条隧道",
|
||||||
|
"tunnel_name": "隧道名称",
|
||||||
|
"delete": "删除",
|
||||||
|
"type_label": "类型:「{{type}}」",
|
||||||
|
"access_method": "访问方式",
|
||||||
|
"local_port": "本地端口",
|
||||||
|
"remote_port": "远程端口",
|
||||||
|
"local_ip": "本地IP",
|
||||||
|
"subdomain": "子域名",
|
||||||
|
"secret_key": "密钥",
|
||||||
|
"save": "保存",
|
||||||
|
"save_success": "保存成功",
|
||||||
|
"save_error": "保存失败"
|
||||||
|
},
|
||||||
|
"frpc": {
|
||||||
|
"form": {
|
||||||
|
"title": "编辑隧道",
|
||||||
|
"description": {
|
||||||
|
"warning": "注意⚠️:选择的「服务端」必须提前配置!",
|
||||||
|
"instruction": "选择客户端和服务端以编辑隧道"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "高级模式",
|
||||||
|
"description": "编辑客户端原始配置文件"
|
||||||
|
},
|
||||||
|
"server": "服务端",
|
||||||
|
"client": "客户端",
|
||||||
|
"comment": {
|
||||||
|
"title": "节点 {{id}} 的备注",
|
||||||
|
"hint": "可以到高级模式修改备注哦!",
|
||||||
|
"empty": "空空如也"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refresh": {
|
||||||
|
"data": "刷新数据"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"title": "租户",
|
||||||
|
"add": "添加租户"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"toggle": "切换语言",
|
||||||
|
"zh": "中文",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"client": {
|
||||||
|
"placeholder": "客户端名称"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"placeholder": "服务端名称"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"placeholder": "请选择...",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"notFound": "未找到结果"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"notFound": "未找到隧道",
|
||||||
|
"placeholder": "隧道名称"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"search": "搜索",
|
||||||
|
"id": {
|
||||||
|
"placeholder": "请输入ID"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"filter": {
|
||||||
|
"placeholder": "根据 {{column}} 筛选"
|
||||||
|
},
|
||||||
|
"noData": "没有数据",
|
||||||
|
"pagination": {
|
||||||
|
"rowsPerPage": "行 每页",
|
||||||
|
"page": "第 {{current}} 页, 共 {{total}} 页",
|
||||||
|
"navigation": {
|
||||||
|
"first": "第一页",
|
||||||
|
"previous": "前一页",
|
||||||
|
"next": "下一页",
|
||||||
|
"last": "最后页"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import { Client, Server } from './pb/common'
|
import { Client, Server } from './pb/common'
|
||||||
import { GetPlatformInfoResponse } from './pb/api_user'
|
import { GetPlatformInfoResponse } from './pb/api_user'
|
||||||
|
import i18next from 'i18next';
|
||||||
|
|
||||||
export const API_PATH = '/api/v1'
|
export const API_PATH = '/api/v1'
|
||||||
export const SET_TOKEN_HEADER = 'x-set-authorization'
|
export const SET_TOKEN_HEADER = 'x-set-authorization'
|
||||||
@@ -8,13 +9,12 @@ export const X_CLIENT_REQUEST_ID = 'x-client-request-id'
|
|||||||
export const LOCAL_STORAGE_TOKEN_KEY = 'token'
|
export const LOCAL_STORAGE_TOKEN_KEY = 'token'
|
||||||
export const ZodPortSchema = z.coerce
|
export const ZodPortSchema = z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(1, {
|
.min(1, { message: i18next.t('validation.portRange.min') })
|
||||||
message: '端口号不能小于 1',
|
.max(65535, { message: i18next.t('validation.portRange.max') })
|
||||||
})
|
export const ZodIPSchema = z.string().regex(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, { message: i18next.t('validation.ipAddress') })
|
||||||
.max(65535, { message: '端口号不能大于 65535' })
|
export const ZodStringSchema = z.string().min(1, { message: i18next.t('validation.required') })
|
||||||
export const ZodIPSchema = z.string().regex(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, { message: '请输入正确的IP地址' })
|
export const ZodEmailSchema = z.string().min(1, { message: i18next.t('validation.required') }).email({ message: i18next.t('auth.email.invalid') })
|
||||||
export const ZodStringSchema = z.string().min(1, { message: '不能为空' })
|
|
||||||
export const ZodEmailSchema = z.string().min(1, { message: '不能为空' }).email('是不是输错了邮箱地址呢?')
|
|
||||||
// .refine((e) => e === "abcd@fg.com", "This email is not in our database")
|
// .refine((e) => e === "abcd@fg.com", "This email is not in our database")
|
||||||
|
|
||||||
export const ExecCommandStr = <T extends Client | Server>(
|
export const ExecCommandStr = <T extends Client | Server>(
|
||||||
|
@@ -1,37 +1,45 @@
|
|||||||
import i18n from 'i18next'
|
import i18next from 'i18next'
|
||||||
import { initReactI18next } from 'react-i18next'
|
import { initReactI18next } from 'react-i18next'
|
||||||
import { atom } from 'nanostores'
|
import { atom } from 'nanostores'
|
||||||
|
import enTranslations from '../i18n/locales/en.json'
|
||||||
|
import zhTranslations from '../i18n/locales/zh.json'
|
||||||
|
|
||||||
const LANGUAGE_KEY = 'LANGUAGE'
|
const LANGUAGE_KEY = 'LANGUAGE'
|
||||||
|
|
||||||
const resources = {
|
// Get initial language from localStorage or default to 'zh'
|
||||||
en: {
|
const getInitialLanguage = () => {
|
||||||
translation: {
|
if (typeof window === 'undefined') return 'zh'
|
||||||
新建: 'New',
|
return localStorage.getItem(LANGUAGE_KEY) || 'zh'
|
||||||
},
|
}
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
translation: {
|
|
||||||
新建: '新建',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const $language = atom('zh')
|
export const $language = atom(getInitialLanguage())
|
||||||
|
|
||||||
|
const i18n = i18next.createInstance()
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: enTranslations,
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
translation: zhTranslations,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lng: getInitialLanguage(),
|
||||||
|
fallbackLng: 'zh',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const setLanguage = async (lng: 'en' | 'zh') => {
|
export const setLanguage = async (lng: 'en' | 'zh') => {
|
||||||
await i18n.changeLanguage(lng)
|
await i18n.changeLanguage(lng)
|
||||||
$language.set(lng)
|
$language.set(lng)
|
||||||
globalThis.localStorage && localStorage.setItem(LANGUAGE_KEY, lng)
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(LANGUAGE_KEY, lng)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
|
||||||
resources,
|
|
||||||
lng: $language.get(),
|
|
||||||
|
|
||||||
interpolation: {
|
|
||||||
escapeValue: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export default i18n
|
export default i18n
|
||||||
|
@@ -43,6 +43,8 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cmdk": "1.0.0",
|
"cmdk": "1.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^11.13.1",
|
||||||
"i18next": "^23.7.18",
|
"i18next": "^23.7.18",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"nanostores": "^0.9.5",
|
"nanostores": "^0.9.5",
|
||||||
|
@@ -1,6 +1,12 @@
|
|||||||
import '@/styles/globals.css'
|
import '@/styles/globals.css'
|
||||||
import type { AppProps } from 'next/app'
|
import type { AppProps } from 'next/app'
|
||||||
|
import { I18nextProvider } from 'react-i18next'
|
||||||
|
import i18n from '@/lib/i18n'
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
return <Component {...pageProps} />
|
return (
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</I18nextProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Providers } from '@/components/providers'
|
import { Providers } from '@/components/providers'
|
||||||
import { RootLayout } from '@/components/layout'
|
import { RootLayout } from '@/components/layout'
|
||||||
import { Header } from '@/components/header'
|
import { Header } from '@/components/header'
|
||||||
@@ -12,12 +14,17 @@ import { ServerSelector } from '@/components/base/server-selector'
|
|||||||
import LoadingCircle from '@/components/base/status'
|
import LoadingCircle from '@/components/base/status'
|
||||||
import { ClientStatus } from '@/lib/pb/api_master'
|
import { ClientStatus } from '@/lib/pb/api_master'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { PlayCircle, StopCircle, RefreshCcw, Eraser, ExternalLink } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const TerminalComponent = dynamic(() => import('@/components/base/read-write-xterm'), {
|
const TerminalComponent = dynamic(() => import('@/components/base/read-write-xterm'), {
|
||||||
ssr: false
|
ssr: false
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function ConsolePage() {
|
export default function ConsolePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [clientID, setClientID] = useState<string | undefined>(undefined)
|
const [clientID, setClientID] = useState<string | undefined>(undefined)
|
||||||
const [clear, setClear] = useState<number>(0)
|
const [clear, setClear] = useState<number>(0)
|
||||||
const [enabled, setEnabled] = useState<boolean>(false)
|
const [enabled, setEnabled] = useState<boolean>(false)
|
||||||
@@ -63,57 +70,126 @@ export default function ConsolePage() {
|
|||||||
};
|
};
|
||||||
}, [clientID, enabled]);
|
}, [clientID, enabled]);
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (enabled) {
|
||||||
|
setEnabled(false)
|
||||||
|
setStatus('error')
|
||||||
|
} else {
|
||||||
|
if (timeoutID) {
|
||||||
|
clearTimeout(timeoutID)
|
||||||
|
}
|
||||||
|
setTimeoutID(setTimeout(() => {
|
||||||
|
setEnabled(true)
|
||||||
|
setStatus('success')
|
||||||
|
}, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (!clientID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClear(Math.random());
|
||||||
|
getClientsStatus({ clientIds: [clientID!], clientType: clientType })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewWindow = () => {
|
||||||
|
window.open(`/terminal?clientType=${clientType.toString()}&clientID=${clientID}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<RootLayout mainHeader={<Header />}>
|
<RootLayout mainHeader={<Header />}>
|
||||||
<div className="w-full">
|
<Card className="w-full h-[calc(100dvh_-_80px)] flex flex-col">
|
||||||
<div className="flex-1 flex-col space-y-2">
|
<CardContent className="p-3 flex-1 flex flex-col gap-2">
|
||||||
<div className="flex flex-1 flex-row gap-2 items-center">
|
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
|
||||||
<div className='items-center'>
|
<div className="flex items-center gap-1.5">
|
||||||
<LoadingCircle status={status} />
|
<LoadingCircle status={status} />
|
||||||
|
<Button
|
||||||
|
disabled={!clientID}
|
||||||
|
variant={enabled ? "destructive" : "default"}
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleConnect}
|
||||||
|
>
|
||||||
|
{enabled ? (
|
||||||
|
<StopCircle className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!clientID}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setClear(Math.random())}
|
||||||
|
>
|
||||||
|
<Eraser className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!clientID}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleNewWindow}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<BaseSelector
|
||||||
|
dataList={[
|
||||||
|
{ value: ClientType.FRPC.toString(), label: "frpc" },
|
||||||
|
{ value: ClientType.FRPS.toString(), label: "frps" }
|
||||||
|
]}
|
||||||
|
setValue={(value) => {
|
||||||
|
setClientType(value === ClientType.FRPC.toString() ? ClientType.FRPC : ClientType.FRPS)
|
||||||
|
}}
|
||||||
|
value={clientType.toString()}
|
||||||
|
label={t('common.clientType')}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
if (enabled) { setEnabled(false) }
|
|
||||||
if (timeoutID) { clearTimeout(timeoutID) }
|
|
||||||
setTimeoutID(setTimeout(() => { setEnabled(true) }, 10))
|
|
||||||
}}>连接</Button>
|
|
||||||
<Button onClick={() => {
|
|
||||||
setClear(Math.random());
|
|
||||||
getClientsStatus({ clientIds: [clientID!], clientType: clientType })
|
|
||||||
}}>刷新</Button>
|
|
||||||
<Button variant="destructive" onClick={() => {
|
|
||||||
setEnabled(false)
|
|
||||||
setClear(Math.random());
|
|
||||||
}}>断开</Button>
|
|
||||||
<Button
|
|
||||||
disabled={clientID === undefined || clientType === undefined}
|
|
||||||
onClick={() => window.open(`/terminal?clientType=${clientType.toString()}&clientID=${clientID}`)}>
|
|
||||||
独立窗口
|
|
||||||
</Button>
|
|
||||||
<BaseSelector
|
|
||||||
dataList={[{ value: ClientType.FRPC.toString(), label: "frpc" }, { value: ClientType.FRPS.toString(), label: "frps" }]}
|
|
||||||
setValue={(value) => { if (value === ClientType.FRPC.toString()) { setClientType(ClientType.FRPC) } else { setClientType(ClientType.FRPS) } }}
|
|
||||||
value={clientType.toString()}
|
|
||||||
label="客户端类型"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{clientType === ClientType.FRPC && <ClientSelector clientID={clientID} setClientID={setClientID} />}
|
|
||||||
{clientType === ClientType.FRPS && <ServerSelector serverID={clientID} setServerID={setClientID} />}
|
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
|
||||||
<div className='flex-1 h-[calc(100dvh_-_180px)]'>
|
{clientType === ClientType.FRPC && (
|
||||||
<TerminalComponent
|
<ClientSelector clientID={clientID} setClientID={setClientID} />
|
||||||
setStatus={setStatus}
|
)}
|
||||||
isLoading={!enabled}
|
{clientType === ClientType.FRPS && (
|
||||||
clientStatus={{
|
<ServerSelector serverID={clientID} setServerID={setClientID} />
|
||||||
clientId: clientID,
|
)}
|
||||||
clientType: clientType,
|
|
||||||
version: { platform: "linux" },
|
<div className={cn(
|
||||||
} as ClientStatus}
|
'flex-1 min-h-0 overflow-hidden',
|
||||||
reset={clear} />
|
'border rounded-lg overflow-hidden'
|
||||||
|
)}>
|
||||||
|
<TerminalComponent
|
||||||
|
setStatus={setStatus}
|
||||||
|
isLoading={!enabled}
|
||||||
|
clientStatus={{
|
||||||
|
clientId: clientID,
|
||||||
|
clientType: clientType,
|
||||||
|
version: { platform: "linux" },
|
||||||
|
} as ClientStatus}
|
||||||
|
reset={clear} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</RootLayout>
|
</RootLayout>
|
||||||
</Providers>
|
</Providers>
|
||||||
)
|
)
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Providers } from '@/components/providers'
|
import { Providers } from '@/components/providers'
|
||||||
import { RootLayout } from '@/components/layout'
|
import { RootLayout } from '@/components/layout'
|
||||||
import { Header } from '@/components/header'
|
import { Header } from '@/components/header'
|
||||||
import { PlatformInfo } from '@/components/platforminfo'
|
import PlatformInfo from '@/components/platforminfo'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
@@ -4,56 +4,90 @@ import { TbBuildingTunnel } from 'react-icons/tb'
|
|||||||
import { LoginComponent } from '@/components/login'
|
import { LoginComponent } from '@/components/login'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export default function Login() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`${inter.className}`}>
|
<main className={`${inter.className} min-h-screen`}>
|
||||||
<Providers>
|
<Providers>
|
||||||
<div
|
{/* Fixed Language Switcher */}
|
||||||
className="absolute text-lg font-medium left-1/2 transform -translate-x-1/2 mt-3 lg:hidden"
|
<div className="fixed top-4 right-4 z-50">
|
||||||
onClick={() => router.push('/')}
|
<LanguageSwitcher />
|
||||||
>
|
</div>
|
||||||
<div className="flex rounded hover:bg-slate-100 p-2">
|
|
||||||
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
|
{/* Mobile Header */}
|
||||||
FRP Panel
|
<div className="fixed w-full flex items-center px-4 py-3 lg:hidden bg-white/80 backdrop-blur-sm border-b z-40">
|
||||||
|
<div
|
||||||
|
className="text-lg font-medium flex items-center"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center rounded hover:bg-slate-100 p-2">
|
||||||
|
<TbBuildingTunnel className="mr-2 h-6 w-6" />
|
||||||
|
{t('app.title')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="container h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
|
||||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
|
<div className="container min-h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||||
|
{/* Left Panel */}
|
||||||
|
<div className="relative hidden h-full flex-col bg-muted p-10 text-zinc-500 lg:flex dark:border-r">
|
||||||
<div className="absolute inset-0 bg-zinc-900"></div>
|
<div className="absolute inset-0 bg-zinc-900"></div>
|
||||||
<div className="relative flex items-center text-lg font-medium" onClick={() => router.push('/')}>
|
<div className="relative z-20">
|
||||||
<div className="flex rounded hover:bg-zinc-800 p-2">
|
<div className="flex items-center text-lg font-medium" onClick={() => router.push('/')}>
|
||||||
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
|
<div className="flex items-center rounded hover:bg-zinc-800 p-2 text-white">
|
||||||
FRP Panel
|
<TbBuildingTunnel className="mr-2 h-8 w-8" />
|
||||||
|
{t('app.title')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
<blockquote className="space-y-2">
|
<blockquote className="space-y-2">
|
||||||
<p className="text-lg">
|
<p className="text-lg leading-relaxed">
|
||||||
A multi node frp webui and for <a href="https://github.com/fatedier/frp">[FRP]</a> server and client
|
{t('app.description')}
|
||||||
management, which makes this project a [Cloudflare Tunnel] or [Tailscale Funnel] open source
|
|
||||||
alternative
|
|
||||||
</p>
|
</p>
|
||||||
<footer className="text-sm">
|
<footer className="text-sm mt-4 opacity-80">
|
||||||
navigate to: <a href="https://github.com/VaalaCat/frp-panel">VaalaCat/frp-panel</a>
|
{t('app.github.navigate')}
|
||||||
|
<a
|
||||||
|
href="https://github.com/VaalaCat/frp-panel"
|
||||||
|
className="hover:text-white hover:underline ml-1"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t('app.github.repo')}
|
||||||
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:p-8 justify-center w-[300px]">
|
|
||||||
<div className="flex flex-col justify-center space-y-6 w-[300px]">
|
{/* Right Panel - Login Form */}
|
||||||
|
<div className="lg:p-8 flex items-center justify-center pt-20 lg:pt-0">
|
||||||
|
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">登录</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<p className="text-sm text-muted-foreground">输入您的账号信息</p>
|
{t('auth.loginTitle')}
|
||||||
</div>
|
</h1>
|
||||||
<div className="w-full justify-center">
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="w-[300px]">
|
{t('auth.inputCredentials')}
|
||||||
<LoginComponent />
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<LoginComponent />
|
||||||
|
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('auth.noAccount')}{' '}
|
||||||
|
<Link
|
||||||
|
className="underline underline-offset-4 hover:text-primary"
|
||||||
|
href="/register"
|
||||||
|
>
|
||||||
|
{t('auth.register')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -4,56 +4,90 @@ import { TbBuildingTunnel } from 'react-icons/tb'
|
|||||||
import { RegisterComponent } from '@/components/register'
|
import { RegisterComponent } from '@/components/register'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export default function Login() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`${inter.className}`}>
|
<main className={`${inter.className} min-h-screen`}>
|
||||||
<Providers>
|
<Providers>
|
||||||
<div
|
{/* Fixed Language Switcher */}
|
||||||
className="absolute text-lg font-medium left-1/2 transform -translate-x-1/2 mt-3 lg:hidden"
|
<div className="fixed top-4 right-4 z-50">
|
||||||
onClick={() => router.push('/')}
|
<LanguageSwitcher />
|
||||||
>
|
</div>
|
||||||
<div className="flex rounded hover:bg-slate-100 p-2">
|
|
||||||
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
|
{/* Mobile Header */}
|
||||||
FRP Panel
|
<div className="fixed w-full flex items-center px-4 py-3 lg:hidden bg-white/80 backdrop-blur-sm border-b z-40">
|
||||||
|
<div
|
||||||
|
className="text-lg font-medium flex items-center"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center rounded hover:bg-slate-100 p-2">
|
||||||
|
<TbBuildingTunnel className="mr-2 h-6 w-6" />
|
||||||
|
{t('app.title')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="container h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
|
||||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
|
<div className="container min-h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||||
|
{/* Left Panel */}
|
||||||
|
<div className="relative hidden h-full flex-col bg-muted p-10 text-zinc-500 lg:flex dark:border-r">
|
||||||
<div className="absolute inset-0 bg-zinc-900"></div>
|
<div className="absolute inset-0 bg-zinc-900"></div>
|
||||||
<div className="relative flex items-center text-lg font-medium" onClick={() => router.push('/')}>
|
<div className="relative z-20">
|
||||||
<div className="flex rounded hover:bg-zinc-800 p-2">
|
<div className="flex items-center text-lg font-medium" onClick={() => router.push('/')}>
|
||||||
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
|
<div className="flex items-center rounded hover:bg-zinc-800 p-2 text-white">
|
||||||
FRP Panel
|
<TbBuildingTunnel className="mr-2 h-8 w-8" />
|
||||||
|
{t('app.title')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
<blockquote className="space-y-2">
|
<blockquote className="space-y-2">
|
||||||
<p className="text-lg">
|
<p className="text-lg leading-relaxed">
|
||||||
A multi node frp webui and for <a href="https://github.com/fatedier/frp">[FRP]</a> server and client
|
{t('app.description')}
|
||||||
management, which makes this project a [Cloudflare Tunnel] or [Tailscale Funnel] open source
|
|
||||||
alternative
|
|
||||||
</p>
|
</p>
|
||||||
<footer className="text-sm">
|
<footer className="text-sm mt-4 opacity-80">
|
||||||
navigate to: <a href="https://github.com/VaalaCat/frp-panel">VaalaCat/frp-panel</a>
|
{t('app.github.navigate')}
|
||||||
|
<a
|
||||||
|
href="https://github.com/VaalaCat/frp-panel"
|
||||||
|
className="hover:text-white hover:underline ml-1"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t('app.github.repo')}
|
||||||
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:p-8 justify-center w-[300px]">
|
|
||||||
<div className="flex flex-col justify-center space-y-6 w-[300px]">
|
{/* Right Panel - Register Form */}
|
||||||
|
<div className="lg:p-8 flex items-center justify-center pt-20 lg:pt-0">
|
||||||
|
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">注册</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<p className="text-sm text-muted-foreground">输入您的账号信息</p>
|
{t('auth.registerTitle')}
|
||||||
</div>
|
</h1>
|
||||||
<div className="w-full justify-center">
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="w-[300px]">
|
{t('auth.inputCredentials')}
|
||||||
<RegisterComponent />
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<RegisterComponent />
|
||||||
|
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('auth.haveAccount')}{' '}
|
||||||
|
<Link
|
||||||
|
className="underline underline-offset-4 hover:text-primary"
|
||||||
|
href="/login"
|
||||||
|
>
|
||||||
|
{t('auth.login')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Providers } from '@/components/providers'
|
import { Providers } from '@/components/providers'
|
||||||
import { RootLayout } from '@/components/layout'
|
import { RootLayout } from '@/components/layout'
|
||||||
import { Header } from '@/components/header'
|
import { Header } from '@/components/header'
|
||||||
@@ -12,12 +14,18 @@ import { BaseSelector } from '@/components/base/selector'
|
|||||||
import { ServerSelector } from '@/components/base/server-selector'
|
import { ServerSelector } from '@/components/base/server-selector'
|
||||||
import LoadingCircle from '@/components/base/status'
|
import LoadingCircle from '@/components/base/status'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
// import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { PlayCircle, StopCircle, RefreshCcw, Eraser } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const LogTerminalComponent = dynamic(() => import('@/components/base/readonly-xterm'), {
|
const LogTerminalComponent = dynamic(() => import('@/components/base/readonly-xterm'), {
|
||||||
ssr: false
|
ssr: false
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function StreamLogPage() {
|
export default function StreamLogPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [clientID, setClientID] = useState<string | undefined>(undefined)
|
const [clientID, setClientID] = useState<string | undefined>(undefined)
|
||||||
const [log, setLog] = useState<string | undefined>(undefined)
|
const [log, setLog] = useState<string | undefined>(undefined)
|
||||||
const [clear, setClear] = useState<number>(0)
|
const [clear, setClear] = useState<number>(0)
|
||||||
@@ -49,18 +57,16 @@ export default function StreamLogPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setClear(Math.random())
|
setClear(Math.random())
|
||||||
setStatus(undefined)
|
setStatus(undefined)
|
||||||
if (!clientID) {
|
if (!clientID || !enabled) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!enabled) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
|
|
||||||
void parseStreaming(
|
void parseStreaming(
|
||||||
abortController,
|
abortController,
|
||||||
clientID!,
|
clientID,
|
||||||
setLog,
|
setLog,
|
||||||
(status: number) => {
|
(status: number) => {
|
||||||
if (status === 200) {
|
if (status === 200) {
|
||||||
@@ -74,50 +80,113 @@ export default function StreamLogPage() {
|
|||||||
setStatus("success")
|
setStatus("success")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort("unmount");
|
abortController.abort("unmount");
|
||||||
setEnabled(false);
|
setEnabled(false);
|
||||||
};
|
};
|
||||||
}, [clientID, enabled]);
|
}, [clientID, enabled]);
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
if (enabled) {
|
||||||
|
setEnabled(false)
|
||||||
|
}
|
||||||
|
if (timeoutID) {
|
||||||
|
clearTimeout(timeoutID)
|
||||||
|
}
|
||||||
|
setTimeoutID(setTimeout(() => { setEnabled(true) }, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setClear(Math.random());
|
||||||
|
if (clientID) {
|
||||||
|
getClientsStatus({ clientIds: [clientID], clientType: clientType })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
setEnabled(false)
|
||||||
|
setClear(Math.random());
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<RootLayout mainHeader={<Header />}>
|
<RootLayout mainHeader={<Header />}>
|
||||||
<div className="w-full">
|
<Card className="w-full h-[calc(100dvh_-_80px)] flex flex-col">
|
||||||
<div className="flex-1 flex-col space-y-2">
|
<CardContent className="p-3 flex-1 flex flex-col gap-2 first-letter:">
|
||||||
<div className="flex flex-1 flex-row gap-2 items-center">
|
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
|
||||||
<div className='items-center'>
|
<div className="flex items-center gap-1.5">
|
||||||
<LoadingCircle status={status} />
|
<LoadingCircle status={status} />
|
||||||
|
<Button
|
||||||
|
disabled={!clientID}
|
||||||
|
variant={enabled ? "destructive" : "default"}
|
||||||
|
className="h-8 px-2 text-sm gap-1.5"
|
||||||
|
onClick={enabled ? handleDisconnect : handleConnect}
|
||||||
|
>
|
||||||
|
{enabled ? (
|
||||||
|
<>
|
||||||
|
<StopCircle className="h-3.5 w-3.5" />
|
||||||
|
{t('common.disconnect')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
{t('common.connect')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!clientID}
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => setClear(Math.random())}
|
||||||
|
>
|
||||||
|
<Eraser className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<BaseSelector
|
||||||
|
dataList={[
|
||||||
|
{ value: ClientType.FRPC.toString(), label: "frpc" },
|
||||||
|
{ value: ClientType.FRPS.toString(), label: "frps" }
|
||||||
|
]}
|
||||||
|
setValue={(value) => {
|
||||||
|
setClientType(value === ClientType.FRPC.toString() ? ClientType.FRPC : ClientType.FRPS)
|
||||||
|
}}
|
||||||
|
value={clientType.toString()}
|
||||||
|
label={t('common.clientType')}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
if (enabled) { setEnabled(false) }
|
|
||||||
if (timeoutID) { clearTimeout(timeoutID) }
|
|
||||||
setTimeoutID(setTimeout(() => { setEnabled(true) }, 10))
|
|
||||||
}}>连接</Button>
|
|
||||||
<Button onClick={() => {
|
|
||||||
setClear(Math.random());
|
|
||||||
getClientsStatus({ clientIds: [clientID!], clientType: clientType })
|
|
||||||
}}>刷新</Button>
|
|
||||||
<Button variant="destructive" onClick={() => {
|
|
||||||
setEnabled(false)
|
|
||||||
setClear(Math.random());
|
|
||||||
}}>断开</Button>
|
|
||||||
<BaseSelector
|
|
||||||
dataList={[{ value: ClientType.FRPC.toString(), label: "frpc" }, { value: ClientType.FRPS.toString(), label: "frps" }]}
|
|
||||||
setValue={(value) => { if (value === ClientType.FRPC.toString()) { setClientType(ClientType.FRPC) } else { setClientType(ClientType.FRPS) } }}
|
|
||||||
value={clientType.toString()}
|
|
||||||
label="客户端类型"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{clientType === ClientType.FRPC && <ClientSelector clientID={clientID} setClientID={setClientID} />}
|
|
||||||
{clientType === ClientType.FRPS && <ServerSelector serverID={clientID} setServerID={setClientID} />}
|
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
|
||||||
<div className='flex-1 h-[calc(100dvh_-_180px)]'>
|
{clientType === ClientType.FRPC && (
|
||||||
<LogTerminalComponent logs={log || ''} reset={clear} />
|
<ClientSelector clientID={clientID} setClientID={setClientID} />
|
||||||
|
)}
|
||||||
|
{clientType === ClientType.FRPS && (
|
||||||
|
<ServerSelector serverID={clientID} setServerID={setClientID} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
'flex-1 min-h-0 overflow-hidden',
|
||||||
|
'border rounded-lg overflow-hidden'
|
||||||
|
)}>
|
||||||
|
<LogTerminalComponent logs={log || ''} reset={clear} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</RootLayout>
|
</RootLayout>
|
||||||
</Providers>
|
</Providers>
|
||||||
)
|
)
|
||||||
|
78
www/pnpm-lock.yaml
generated
@@ -110,6 +110,12 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: 1.0.0
|
specifier: 1.0.0
|
||||||
version: 1.0.0(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
version: 1.0.0(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
|
framer-motion:
|
||||||
|
specifier: ^11.13.1
|
||||||
|
version: 11.13.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^23.7.18
|
specifier: ^23.7.18
|
||||||
version: 23.7.18
|
version: 23.7.18
|
||||||
@@ -313,59 +319,59 @@ packages:
|
|||||||
resolution: {integrity: sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ==}
|
resolution: {integrity: sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@14.0.4':
|
'@next/swc-darwin-arm64@14.0.4':
|
||||||
resolution: {integrity: sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==}
|
resolution: {integrity: sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==, tarball: https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@14.0.4':
|
'@next/swc-darwin-x64@14.0.4':
|
||||||
resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==}
|
resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==, tarball: https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@14.0.4':
|
'@next/swc-linux-arm64-gnu@14.0.4':
|
||||||
resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==}
|
resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==, tarball: https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@14.0.4':
|
'@next/swc-linux-arm64-musl@14.0.4':
|
||||||
resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==}
|
resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==, tarball: https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@14.0.4':
|
'@next/swc-linux-x64-gnu@14.0.4':
|
||||||
resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==}
|
resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==, tarball: https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@14.0.4':
|
'@next/swc-linux-x64-musl@14.0.4':
|
||||||
resolution: {integrity: sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==}
|
resolution: {integrity: sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==, tarball: https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@14.0.4':
|
'@next/swc-win32-arm64-msvc@14.0.4':
|
||||||
resolution: {integrity: sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==}
|
resolution: {integrity: sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==, tarball: https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-ia32-msvc@14.0.4':
|
'@next/swc-win32-ia32-msvc@14.0.4':
|
||||||
resolution: {integrity: sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==}
|
resolution: {integrity: sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==, tarball: https://registry.npmmirror.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@14.0.4':
|
'@next/swc-win32-x64-msvc@14.0.4':
|
||||||
resolution: {integrity: sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==}
|
resolution: {integrity: sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==, tarball: https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -383,7 +389,7 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@protobuf-ts/plugin-framework@2.9.3':
|
'@protobuf-ts/plugin-framework@2.9.3':
|
||||||
@@ -1095,7 +1101,7 @@ packages:
|
|||||||
'@xterm/xterm': ^5.0.0
|
'@xterm/xterm': ^5.0.0
|
||||||
|
|
||||||
'@xterm/xterm@5.5.0':
|
'@xterm/xterm@5.5.0':
|
||||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==, tarball: https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
@@ -1360,6 +1366,9 @@ packages:
|
|||||||
damerau-levenshtein@1.0.8:
|
damerau-levenshtein@1.0.8:
|
||||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||||
|
|
||||||
|
date-fns@4.1.0:
|
||||||
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==, tarball: https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz}
|
||||||
|
|
||||||
debug@3.2.7:
|
debug@3.2.7:
|
||||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1430,7 +1439,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-lKoz10iCYlP1WtRYdh5MvocQPWVRoI7ysp6qf18bmeBgR8abE6+I2CsfyNKztRDZvhdWc+krKT6wS7Neg8sw3A==}
|
resolution: {integrity: sha512-lKoz10iCYlP1WtRYdh5MvocQPWVRoI7ysp6qf18bmeBgR8abE6+I2CsfyNKztRDZvhdWc+krKT6wS7Neg8sw3A==}
|
||||||
|
|
||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, tarball: https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz}
|
||||||
|
|
||||||
emoji-regex@9.2.2:
|
emoji-regex@9.2.2:
|
||||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||||
@@ -1631,11 +1640,25 @@ packages:
|
|||||||
fraction.js@4.3.7:
|
fraction.js@4.3.7:
|
||||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||||
|
|
||||||
|
framer-motion@11.13.1:
|
||||||
|
resolution: {integrity: sha512-F40tpGTHByhn9h3zdBQPcEro+pSLtzARcocbNqAyfBI+u9S+KZuHH/7O9+z+GEkoF3eqFxfvVw0eBDytohwqmQ==, tarball: https://registry.npmmirror.com/framer-motion/-/framer-motion-11.13.1.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
'@emotion/is-prop-valid': '*'
|
||||||
|
react: ^18.0.0
|
||||||
|
react-dom: ^18.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@emotion/is-prop-valid':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fs.realpath@1.0.0:
|
fs.realpath@1.0.0:
|
||||||
resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
|
resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
@@ -1807,7 +1830,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==}
|
resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0:
|
is-fullwidth-code-point@3.0.0:
|
||||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, tarball: https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
is-generator-function@1.0.10:
|
is-generator-function@1.0.10:
|
||||||
@@ -2000,6 +2023,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==}
|
resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
||||||
|
motion-dom@11.13.0:
|
||||||
|
resolution: {integrity: sha512-Oc1MLGJQ6nrvXccXA89lXtOqFyBmvHtaDcTRGT66o8Czl7nuA8BeHAd9MQV1pQKX0d2RHFBFaw5g3k23hQJt0w==, tarball: https://registry.npmmirror.com/motion-dom/-/motion-dom-11.13.0.tgz}
|
||||||
|
|
||||||
|
motion-utils@11.13.0:
|
||||||
|
resolution: {integrity: sha512-lq6TzXkH5c/ysJQBxgLXgM01qwBH1b4goTPh57VvZWJbVJZF/0SB31UWEn4EIqbVPf3au88n2rvK17SpDTja1A==, tarball: https://registry.npmmirror.com/motion-utils/-/motion-utils-11.13.0.tgz}
|
||||||
|
|
||||||
ms@2.1.2:
|
ms@2.1.2:
|
||||||
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||||
|
|
||||||
@@ -2432,7 +2461,7 @@ packages:
|
|||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, tarball: https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
string-width@5.1.2:
|
string-width@5.1.2:
|
||||||
@@ -2541,7 +2570,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||||
|
|
||||||
tslib@2.6.2:
|
tslib@2.6.2:
|
||||||
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==, tarball: https://registry.npmmirror.com/tslib/-/tslib-2.6.2.tgz}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
@@ -2661,7 +2690,7 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, tarball: https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
wrap-ansi@8.1.0:
|
wrap-ansi@8.1.0:
|
||||||
@@ -3870,6 +3899,8 @@ snapshots:
|
|||||||
|
|
||||||
damerau-levenshtein@1.0.8: {}
|
damerau-levenshtein@1.0.8: {}
|
||||||
|
|
||||||
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
debug@3.2.7:
|
debug@3.2.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -4268,6 +4299,15 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@4.3.7: {}
|
fraction.js@4.3.7: {}
|
||||||
|
|
||||||
|
framer-motion@11.13.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||||
|
dependencies:
|
||||||
|
motion-dom: 11.13.0
|
||||||
|
motion-utils: 11.13.0
|
||||||
|
tslib: 2.6.2
|
||||||
|
optionalDependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
|
||||||
fs.realpath@1.0.0: {}
|
fs.realpath@1.0.0: {}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
@@ -4637,6 +4677,10 @@ snapshots:
|
|||||||
|
|
||||||
minipass@7.0.4: {}
|
minipass@7.0.4: {}
|
||||||
|
|
||||||
|
motion-dom@11.13.0: {}
|
||||||
|
|
||||||
|
motion-utils@11.13.0: {}
|
||||||
|
|
||||||
ms@2.1.2: {}
|
ms@2.1.2: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
@@ -8,3 +8,13 @@ export const $userInfo = atom<User | undefined>()
|
|||||||
export const $statusOnline = atom<boolean>(false)
|
export const $statusOnline = atom<boolean>(false)
|
||||||
export const $token = persistentAtom<string | undefined>(LOCAL_STORAGE_TOKEN_KEY)
|
export const $token = persistentAtom<string | undefined>(LOCAL_STORAGE_TOKEN_KEY)
|
||||||
export const $platformInfo = atom<GetPlatformInfoResponse | undefined>()
|
export const $platformInfo = atom<GetPlatformInfoResponse | undefined>()
|
||||||
|
|
||||||
|
// 创建持久化的语言设置
|
||||||
|
export const $language = persistentAtom<string>(
|
||||||
|
'user-language',
|
||||||
|
'zh',
|
||||||
|
{
|
||||||
|
encode: JSON.stringify,
|
||||||
|
decode: JSON.parse
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@@ -4,87 +4,97 @@ module.exports = {
|
|||||||
content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
|
content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
|
||||||
prefix: '',
|
prefix: '',
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: 'true',
|
center: true,
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
screens: {
|
screens: {
|
||||||
'2xl': '1400px'
|
'2xl': '1400px'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--input))',
|
||||||
ring: 'hsl(var(--ring))',
|
ring: 'hsl(var(--ring))',
|
||||||
background: 'hsl(var(--background))',
|
background: 'hsl(var(--background))',
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'hsl(var(--foreground))',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: 'hsl(var(--primary-foreground))'
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
foreground: 'hsl(var(--secondary-foreground))'
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
foreground: 'hsl(var(--accent-foreground))'
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
foreground: 'hsl(var(--popover-foreground))'
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: 'hsl(var(--card-foreground))'
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||||
foreground: 'hsl(var(--sidebar-foreground))',
|
foreground: 'hsl(var(--sidebar-foreground))',
|
||||||
primary: 'hsl(var(--sidebar-primary))',
|
primary: 'hsl(var(--sidebar-primary))',
|
||||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||||
accent: 'hsl(var(--sidebar-accent))',
|
accent: 'hsl(var(--sidebar-accent))',
|
||||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||||
border: 'hsl(var(--sidebar-border))',
|
border: 'hsl(var(--sidebar-border))',
|
||||||
ring: 'hsl(var(--sidebar-ring))'
|
ring: 'hsl(var(--sidebar-ring))'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'calc(var(--radius) - 2px)',
|
||||||
sm: 'calc(var(--radius) - 4px)'
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'accordion-down': {
|
'accordion-down': {
|
||||||
from: {
|
from: { height: 0 },
|
||||||
height: '0'
|
to: { height: 'var(--radix-accordion-content-height)' }
|
||||||
},
|
},
|
||||||
to: {
|
'accordion-up': {
|
||||||
height: 'var(--radix-accordion-content-height)'
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
}
|
to: { height: 0 }
|
||||||
},
|
},
|
||||||
'accordion-up': {
|
ping: {
|
||||||
from: {
|
'75%, 100%': {
|
||||||
height: 'var(--radix-accordion-content-height)'
|
transform: 'scale(1.5)',
|
||||||
},
|
opacity: '0'
|
||||||
to: {
|
}
|
||||||
height: '0'
|
},
|
||||||
}
|
breathe: {
|
||||||
}
|
'0%, 100%': {
|
||||||
},
|
transform: 'scale(0.95)',
|
||||||
animation: {
|
opacity: '0.5'
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
},
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
'50%': {
|
||||||
}
|
transform: 'scale(1)',
|
||||||
}
|
opacity: '1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'ping-slow': 'ping 1.5s ease-in-out infinite',
|
||||||
|
'breathe': 'breathe 2s ease-in-out infinite',
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [require('tailwindcss-animate')],
|
||||||
}
|
}
|
||||||
|