feat: i18n

This commit is contained in:
VaalaCat
2024-12-07 00:46:12 +08:00
committed by Vaala Cat
parent 97138d38ae
commit 0cf9b6974f
88 changed files with 2994 additions and 1302 deletions

View File

@@ -9,7 +9,9 @@ RUN corepack enable && corepack prepare pnpm@latest-9 --activate && pnpm install
COPY www/api/ ./api
COPY www/components/ ./components
COPY www/config/ ./config
COPY www/hooks/ ./hooks
COPY www/i18n/ ./i18n
COPY www/lib/ ./lib
COPY www/pages/ ./pages
COPY www/public/ ./public

274
README.md
View File

@@ -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
[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:
- 客户端配置可中心化管理
- 多服务端配置管理
- 可视化配置界面
- 简化运行所需要的配置
的更强更完善的 frp
- 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)
![](./doc/frp-panel-demo.gif)
## 项目使用说明
## 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
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
注意 ⚠client 和 server 的启动指令可能会随着项目更新而改变,虽然在项目迭代时会注意前后兼容,但仍难以完全适配,因此 client 和 server 的启动指令以 master 生成为准
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
# 推荐
# MASTER_RPC_HOST要改成你服务器的外部IP
# APP_GLOBAL_SECRET注意不要泄漏客户端和服务端的是通过Master生成的
# Recommended
# Change MASTER_RPC_HOST to the external IP of your server
# Be 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 \
@@ -59,16 +55,17 @@ docker run -d \
-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预留的端口
# 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 \ # 数据存储位置
-e APP_GLOBAL_SECRET=your_secret \ # Mastersecret注意不要泄漏客户端和服务端的是通过Master生成的
-e MASTER_RPC_HOST=0.0.0.0 \ # 这里要改成你服务器的外部IP
-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
```
@@ -78,7 +75,7 @@ docker run -d -p 9000:9000 \ # API控制台端口
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复制的参数
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
@@ -87,14 +84,14 @@ docker run -d \
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复制的参数
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
注意修改 IP
Note: Modify the IP
```bash
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
```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
```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
@@ -122,34 +121,32 @@ MASTER_RPC_HOST=IP
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 使用 本程序 控制
Linux 安装后使用示例:
Examples of using Linux after installation:
```bash
systemctl stop frpp
systemctl start frpp
```
Windows 安装后使用示例:
Examples of using Windows after installation:
```
C:/frpp/frpp.exe start
@@ -157,93 +154,62 @@ C:/frpp/frpp.exe stop
C:/frpp/frpp.exe uninstall
```
### 配置说明
## Project Development Guide
| 类型 | 环境变量名 | 默认值 | 描述 |
|--------|-------------------------------------|--------------------|----------------------------------------------------------------|
| 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` | - | 客户端密钥 |
### Platform Architecture Design
## 项目开发指南
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中心化的存储配置文件和连接信息。
然后是 frpc 和 frps。原版是需要在两边分别写配置文件的。那么既然原版已经支持了就不用在走原版的路子我们直接不支持配置文件所有的配置都必须从 master 获取。
其次还要考虑到与原版的兼容问题frp-panel 的客户端/服务端都必须要能连上官方 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.
![arch](doc/arch.png)
### 开发
### Development
项目包含三个角色
The project includes three roles:
1. Master: 控制节点,接受来自前端的请求并负责管理 Client Server
2. Server: 服务端,受控制节点控制,负责对客户端提供服务,包含 frps 和 rpc(用于连接 Master)服务
3. Client: 客户端,受控制节点控制,包含 frpc rpc(用于连接 Master)服务
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 # 主要业务逻辑
| |-- client # 客户端逻辑(这里指的是frp-panel的客户端)
| |-- master # frp-panel 控制平面负责处理前端请求并且使用rpc管理frp-panelserverclient
| | |-- auth # 认证模块,包含用户认证和客户端认证
| | |-- client # 客户端模块包含前端管理客户端的各种API
| | |-- server # 服务端模块包含前端管理服务端的各种API
| | `-- user # 用户模块,包含用户管理、用户信息获取等
| `-- server # 服务端逻辑这里指的是frp-panel的服务端
|-- cache # 缓存用于存储frps的认证token
|-- cmd # 命令行入口main函数的所在地负责按需启动各个模块
|-- 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,任何和数据库相关的操作会调用这个库
|-- doc # 文档
|-- idl # idl定义
|-- middleware # api的中间件包含JWT和context相关用于处理api请求鉴权通过后会把用户信息注入到context可以通过common包获取
|-- models # 数据库模型,用于定义数据库表。同时包含实体定义
|-- pb # protobuf生成的pb文件
|-- rpc # 各种rpc的所在地包含Client/Server调用Master的逻辑也包含Master使用Stream调用ClientServer的逻辑
|-- services # 各种需要在内存中持久运行的模块,这个包可以管理各个服务的运行/停止
| |-- api # api服务运行需要外部传入一个ginRouter
| |-- client # frp的客户端即frpc可以控制frpc的各种配置/开始与停止
| |-- master # master服务包含rpc的服务端定义接收到rpc请求后会调用biz包处理逻辑
| |-- rpcclient # 有状态的rpc客户端因为rpcclient都没有公网ip因此在rpc client启动时会调用masterstream长连接rpc建立连接后MasterClient通过这个包通信
| `-- server # frp的服务端即frps可以控制frps的各种配置/开始与停止
|-- tunnel # tunnel模块用于管理tunnel也就是管理frpc和frps服务
|-- 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 # 定时运行的任务比如每30秒更新一次配置文件
|-- watcher # Scheduled tasks, e.g., updating configuration files every 30 seconds
`-- www
|-- api
|-- components # 这里面有一个apitest组件用于测试
|-- components # There is an apitest component here for testing
| `-- ui
|-- lib
| `-- pb
@@ -252,38 +218,54 @@ C:/frpp/frpp.exe uninstall
|-- store
|-- styles
`-- types
```
### 调试启动方式:
### Debugging and Startup Methods:
- 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>`
- 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:
![structure](doc/callvis.svg)
### 本体配置说明
### 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.
### 一些图片
## Screenshots
![](doc/platform_info.png)
![](doc/login.png)
![](doc/register.png)
![](doc/clients_menu.png)
![](doc/server_menu.png)
![](doc/create_client.png)
![](doc/create_server.png)
![](doc/edit_client.png)
![](doc/edit_client_adv.png)
![](doc/edit_server.png)
![](doc/edit_server_adv.png)
![](doc/traffic_statistics.png)
### Index Page
![Index Page](doc/en_index.png)
### Server List
![Server List](doc/en_server_list.png)
### Server Edit
![Server Edit](doc/en_server_edit.png)
### Server Edit Advanced
![Server Edit Advanced](doc/en_server_edit_adv.png)
### Client List
![Client List](doc/en_client_list.png)
### Client Edit
![Client Edit](doc/en_client_edit.png)
### Client Edit Advanced
![Client Edit Advanced](doc/en_client_edit_adv.png)
### Client Stats
![Client Stats](doc/en_client_stats.png)
### Realtime Log
![Realtime Log](doc/en_realtime_log.png)
### Remote Console
![Remote Console](doc/en_remote_console.png)

View File

@@ -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)
![](./doc/frp-panel-demo.gif)
## 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.
![arch](doc/arch.png)
### 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:
![structure](doc/callvis.svg)
### 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
![](doc/platform_info.png)
![](doc/login.png)
![](doc/register.png)
![](doc/clients_menu.png)
![](doc/server_menu.png)
![](doc/create_client.png)
![](doc/create_server.png)
![](doc/edit_client.png)
![](doc/edit_client_adv.png)
![](doc/edit_server.png)
![](doc/edit_server_adv.png)
![](doc/traffic_statistics.png)

306
README_zh.md Normal file
View 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)
![](./doc/frp-panel-demo.gif)
## 项目使用说明
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 服务。这样的话就可以做到配置文件/不要配置文件都能完美工作了。
总的说来架构还是很简单的。
![arch](doc/arch.png)
### 开发
项目包含三个角色
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 文件,项目内置了样例配置文件,可以按照自己的需求进行修改
详细架构调用图
![structure](doc/callvis.svg)
### 本体配置说明
[settings.go](conf/settings.go)
这里有详细的配置参数解释,需要进一步修改配置请参考该文件
## 截图展示
### 首页
![首页](doc/cn_index.png)
### 服务器列表
![服务器列表](doc/cn_server_list.png)
### 服务器编辑
![服务器编辑](doc/cn_server_edit.png)
### 服务器高级编辑
![服务器高级编辑](doc/cn_server_edit_adv.png)
### 客户端列表
![客户端列表](doc/cn_client_list.png)
### 客户端编辑
![客户端编辑](doc/cn_client_edit.png)
### 客户端高级编辑
![客户端高级编辑](doc/cn_client_edit_adv.png)
### 客户端统计
![客户端统计](doc/cn_client_stats.png)
### 实时日志
![实时日志](doc/cn_realtime_log.png)
### 远程控制台
![远程控制台](doc/cn_remote_console.png)

View File

@@ -56,7 +56,7 @@ func GetClientsStatus(c context.Context, req *pb.GetClientsStatusRequest) (*pb.G
proto.Unmarshal(tresp.GetData(), clientVersion)
connectTime, ok := mgr.ConnectTime(clientID)
if !ok {
connectTime = endTime
connectTime = time.Time{}
}
resps[clientID] = &pb.ClientStatus{
@@ -66,7 +66,7 @@ func GetClientsStatus(c context.Context, req *pb.GetClientsStatusRequest) (*pb.G
Ping: int32(pingTime),
Version: clientVersion,
Addr: lo.ToPtr(mgr.ClientAddr(clientID)),
ConnectTime: lo.ToPtr(int32(endTime.Sub(connectTime).Seconds())),
ConnectTime: lo.ToPtr(int32(connectTime.UnixMilli())),
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

BIN
doc/cn_client_edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
doc/cn_client_edit_adv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
doc/cn_client_list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
doc/cn_client_stats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
doc/cn_index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

BIN
doc/cn_realtime_log.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

BIN
doc/cn_remote_console.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

BIN
doc/cn_server_edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

BIN
doc/cn_server_edit_adv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

BIN
doc/cn_server_list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 543 KiB

BIN
doc/en_client_edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

BIN
doc/en_client_edit_adv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

BIN
doc/en_client_list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
doc/en_client_stats.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

BIN
doc/en_index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

BIN
doc/en_realtime_log.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

BIN
doc/en_remote_console.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

BIN
doc/en_server_edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
doc/en_server_edit_adv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

BIN
doc/en_server_list.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

View File

@@ -6,12 +6,13 @@ import {
UpdateUserInfoRequest,
UpdateUserInfoResponse,
} from '@/lib/pb/api_user'
import { $userInfo } from '@/store/user'
import { $statusOnline, $userInfo } from '@/store/user'
import { BaseResponse } from '@/types/api'
export const getUserInfo = async (req: GetUserInfoRequest) => {
const res = await http.post(API_PATH + '/user/get', GetUserInfoRequest.toJson(req))
$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)
}

View File

@@ -27,63 +27,17 @@ import { RegisterAndLogin } from "./header"
import { useRouter } from "next/navigation"
import { useQuery } from "@tanstack/react-query"
import { getPlatformInfo } from "@/api/platform"
const data = {
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,
},
]
}
import { teams, getNavItems } from '@/config/nav'
import { useTranslation } from 'react-i18next'
export interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
chrildren?: React.ReactNode
children?: React.ReactNode
footer?: React.ReactNode
}
export function AppSidebar({ ...props }: AppSidebarProps) {
const router = useRouter()
const { t } = useTranslation()
const userInfo = useStore($userInfo)
const { data: platformInfo } = useQuery({
queryKey: ['platformInfo'],
@@ -107,12 +61,12 @@ export function AppSidebar({ ...props }: AppSidebarProps) {
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold font-mono">
Frp-Panel
{t('app.title')}
</span>
<span className="truncate text-xs font-mono">frp隧道面板</span>
<span className="truncate text-xs font-mono">{t('app.subtitle')}</span>
</div>
</SidebarMenuButton>
<NavMain items={data.navMain} />
<NavMain items={getNavItems(t)} />
</SidebarHeader>
<SidebarContent>
{props.children}

View File

@@ -1,14 +1,23 @@
"use client"
import React from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { listClient } from '@/api/client'
import { Combobox } from './combobox'
import { useTranslation } from 'react-i18next'
export interface ClientSelectorProps {
clientID?: string
setClientID: (clientID: string) => 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 [keyword, setKeyword] = React.useState('')
@@ -22,8 +31,11 @@ export const ClientSelector: React.FC<ClientSelectorProps> = ({ clientID, setCli
return (
<Combobox
placeholder='客户端名称'
dataList={clientList?.clients.map((client) => ({ value: client.id || '', label: client.id || '' })) || []}
placeholder={t('selector.client.placeholder')}
dataList={clientList?.clients.map((client) => ({
value: client.id || '',
label: client.id || ''
})) || []}
setValue={handleClientChange}
value={clientID}
onKeyWordChange={setKeyword}

View File

@@ -1,42 +1,70 @@
"use client"
import { Popover, PopoverTrigger } from "@radix-ui/react-popover"
import { Badge } from "../ui/badge"
import { ClientStatus } from "@/lib/pb/api_master"
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 }) => {
const { t, i18n } = useTranslation()
const locale = i18n.language === 'zh' ? zhCN : enUS
const connectTime = clientStatus.connectTime ?
formatDistanceToNow(new Date(clientStatus.connectTime), {
addSuffix: true,
locale
}) : '-'
return (
<Popover>
<PopoverTrigger className='flex items-center'>
<Badge variant={"secondary"} className='text-nowrap rounded-full h-6'>
{clientStatus.version?.gitVersion}
<Badge
variant="secondary"
className='text-nowrap rounded-full h-6 hover:bg-secondary/80 transition-colors text-sm'
>
{clientStatus.version?.gitVersion || 'Unknown'}
</Badge>
</PopoverTrigger>
<PopoverContent className="w-fit overflow-auto max-w-72 max-h-72 p-4 bg-white rounded-lg shadow-lg">
<h3 className="text-lg font-semibold mb-4 text-center"></h3>
<div className="flex justify-between mb-2">
<span className="font-medium">:</span>
<span>{clientStatus.version?.gitVersion}</span>
<PopoverContent className="w-72 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-border">
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-base font-semibold mb-3 text-center text-foreground">
{t('client.detail.title')}
</h3>
<div className="space-y-2">
<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.version')}</span>
<span className="text-sm text-foreground">{clientStatus.version?.gitVersion || '-'}</span>
</div>
<div className="flex justify-between mb-2">
<span className="font-medium">:</span>
<span>{clientStatus.version?.buildDate}</span>
<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.buildDate')}</span>
<span className="text-sm text-foreground">{clientStatus.version?.buildDate || '-'}</span>
</div>
<div className="flex justify-between mb-2">
<span className="font-medium">Go版本:</span>
<span>{clientStatus.version?.goVersion}</span>
<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.goVersion')}</span>
<span className="text-sm text-foreground">{clientStatus.version?.goVersion || '-'}</span>
</div>
<div className="flex justify-between mb-2">
<span className="font-medium">:</span>
<span>{clientStatus.version?.platform}</span>
<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.platform')}</span>
<span className="text-sm text-foreground">{clientStatus.version?.platform || '-'}</span>
</div>
<div className="flex justify-between mb-2">
<span className="font-medium">:</span>
<span>{clientStatus.addr}</span>
<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 mb-2">
<span className="font-medium">:</span>
<span>{clientStatus.connectTime}</span>
<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>
</Popover>
)

View File

@@ -2,10 +2,10 @@
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useDebouncedCallback } from 'use-debounce';
import { useDebouncedCallback } from 'use-debounce'
import { useTranslation } from 'react-i18next'
import {
Command,
CommandEmpty,
@@ -33,7 +33,19 @@ export interface ComboboxProps {
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 debounced = useDebouncedCallback(
(v) => {
@@ -42,6 +54,10 @@ export function Combobox({ value, setValue, dataList, placeholder, notFoundText,
500,
);
const defaultPlaceholder = t('selector.common.placeholder')
const defaultNotFoundText = t('selector.common.notFound')
const loadingText = t('selector.common.loading')
return (
<Popover open={open} onOpenChange={(open) => {
onOpenChange && onOpenChange()
@@ -55,15 +71,19 @@ export function Combobox({ value, setValue, dataList, placeholder, notFoundText,
>
{value
? (dataList.find((item) => item.value === value)?.label || value)
: (placeholder||"请选择...")}
: (placeholder || defaultPlaceholder)}
<ChevronsUpDown className="opacity-50 h-[12px] w-[12px]" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput value={keyword} onValueChange={(v) => debounced(v)} placeholder={`${placeholder||"请选择..."}`} />
<CommandInput
value={keyword}
onValueChange={(v) => debounced(v)}
placeholder={placeholder || defaultPlaceholder}
/>
<CommandList>
<CommandEmpty>{isLoading ? "加载中..." : notFoundText||"未找到结果"}</CommandEmpty>
<CommandEmpty>{isLoading ? loadingText : (notFoundText || defaultNotFoundText)}</CommandEmpty>
<CommandGroup>
{dataList.map((item) => (
<CommandItem

View File

@@ -18,6 +18,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import React from 'react'
import { Input } from '@/components/ui/input'
import { DataTablePagination } from './data_table_pagination'
import { useTranslation } from 'react-i18next'
interface DataTableProps<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>) {
const { t } = useTranslation()
return (
<div>
{filterColumnName && (
<div className="flex flex-1 items-center py-4">
<Input
placeholder={`根据 ${filterColumnName} 筛选`}
placeholder={t('table.filter.placeholder', { column: filterColumnName })}
value={(table.getColumn(filterColumnName)?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn(filterColumnName)?.setFilterValue(event.target.value)}
className="max-w-sm"
@@ -66,7 +69,7 @@ export function DataTable<TData, TValue>({ columns, filterColumnName, table }: D
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{t('table.noData')}
</TableCell>
</TableRow>
)}

View File

@@ -1,20 +1,18 @@
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
}
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
const { t } = useTranslation()
return (
<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 space-x-2">
<Select
@@ -34,10 +32,13 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
))}
</SelectContent>
</Select>
<p className="text-sm font-medium"> </p>
<p className="text-sm font-medium">{t('table.pagination.rowsPerPage')}</p>
</div>
<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 className="flex items-center space-x-2">
<Button
@@ -46,7 +47,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only"></span>
<span className="sr-only">{t('table.pagination.navigation.first')}</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
@@ -55,7 +56,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only"></span>
<span className="sr-only">{t('table.pagination.navigation.previous')}</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
@@ -64,7 +65,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only"></span>
<span className="sr-only">{t('table.pagination.navigation.next')}</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
@@ -73,7 +74,7 @@ export function DataTablePagination<TData>({ table }: DataTablePaginationProps<T
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only"></span>
<span className="sr-only">{t('table.pagination.navigation.last')}</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>

View File

@@ -1,6 +1,9 @@
"use client"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { useState } from "react"
import { useTranslation } from "react-i18next"
export interface IdInputProps {
setKeyword: (keyword: string) => void
@@ -9,13 +12,27 @@ export interface IdInputProps {
}
export const IdInput: React.FC<IdInputProps> = ({ setKeyword, keyword, refetchTrigger }) => {
const { t } = useTranslation()
const [input, setInput] = useState(keyword)
return <div className="flex flex-1 flex-row gap-2">
<Input className="max-w-40 h-auto" defaultValue={keyword} onChange={(e) => setInput(e.target.value)}></Input>
<Button variant="outline" size={'sm'} onClick={() => {
return (
<div className="flex flex-1 flex-row gap-2">
<Input
className="max-w-40 h-auto"
defaultValue={keyword}
placeholder={t('input.id.placeholder')}
onChange={(e) => setInput(e.target.value)}
/>
<Button
variant="outline"
size="sm"
onClick={() => {
setKeyword(input)
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
}}></Button>
</div >
}}
>
{t('input.search')}
</Button>
</div>
)
}

View File

@@ -1,5 +1,8 @@
"use client"
import React from 'react'
import { Combobox } from './combobox'
import { useTranslation } from 'react-i18next'
export interface ProxySelectorProps {
proxyName?: string
@@ -7,12 +10,23 @@ export interface ProxySelectorProps {
proxyNames: string[]
}
export const ProxySelector: React.FC<ProxySelectorProps> = ({ proxyName, proxyNames ,setProxyname }) => {
return <Combobox
dataList={proxyNames.map((name) => ({ value: name, label: name }))}
export const ProxySelector: React.FC<ProxySelectorProps> = ({
proxyName,
proxyNames,
setProxyname
}) => {
const { t } = useTranslation()
return (
<Combobox
dataList={proxyNames.map((name) => ({
value: name,
label: name
}))}
value={proxyName}
setValue={setProxyname}
notFoundText="未找到隧道"
placeholder="隧道名称"
notFoundText={t('selector.proxy.notFound')}
placeholder={t('selector.proxy.placeholder')}
/>
)
}

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import {
@@ -10,6 +12,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { useTranslation } from "react-i18next"
export interface BaseSelectorProps {
value?: string
@@ -21,22 +24,31 @@ export interface BaseSelectorProps {
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 (
<Select onValueChange={setValue} value={value} onOpenChange={onOpenChange}>
<SelectTrigger className={cn("w-full", className)}>
<SelectValue placeholder={placeholder || "请选择"} />
<SelectValue placeholder={placeholder || defaultPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{label}</SelectLabel>
{
dataList.map((item) => (
{label && <SelectLabel>{label}</SelectLabel>}
{dataList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
))}
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -1,14 +1,23 @@
"use client"
import React from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { listServer } from '@/api/server'
import { Combobox } from './combobox'
import { useTranslation } from 'react-i18next'
export interface ServerSelectorProps {
serverID?: string
setServerID: (serverID: string) => 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 [keyword, setKeyword] = React.useState('')
@@ -20,15 +29,20 @@ export const ServerSelector: React.FC<ServerSelectorProps> = ({ serverID, setSer
placeholderData: keepPreviousData,
})
return (<Combobox
placeholder='服务端名称'
return (
<Combobox
placeholder={t('selector.server.placeholder')}
value={serverID}
setValue={handleServerChange}
dataList={serverList?.servers.map((server) => ({ value: server.id || '', label: server.id || '' })) || []}
dataList={serverList?.servers.map((server) => ({
value: server.id || '',
label: server.id || ''
})) || []}
onKeyWordChange={setKeyword}
onOpenChange={() => {
onOpenChange && onOpenChange()
refetchServers()
}}
/>)
/>
)
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
type Status = 'loading' | 'success' | 'error' ;
@@ -13,16 +13,6 @@ const statusColors: Record<Status, { outer: string; inner: string }> = {
};
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' }
if (status) {
const { outer: o, inner: i } = statusColors[status];
@@ -32,8 +22,18 @@ const LoadingCircle: React.FC<LoadingCircleProps> = ({ status }) => {
return (
<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 className={`absolute top-1 left-1 w-4 h-4 rounded-full ${inner} transition-opacity duration-500 ${isVisible ? 'opacity-100' : 'opacity-50'}`} />
<div
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>
);

View File

@@ -9,25 +9,28 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { useTranslation } from "react-i18next"
export function ProxyTrafficBarChart({ proxyInfo }:{ proxyInfo: ProxyInfo }) {
const { t } = useTranslation()
const data = [
{
name: "入站",
Today: Number(proxyInfo.todayTrafficIn),
History: Number(proxyInfo.historyTrafficIn),
name: t('traffic.chart.inbound'),
[t('traffic.chart.today')]: Number(proxyInfo.todayTrafficIn),
[t('traffic.chart.history')]: Number(proxyInfo.historyTrafficIn),
},
{
name: "出站",
Today: Number(proxyInfo.todayTrafficOut),
History: Number(proxyInfo.historyTrafficOut),
name: t('traffic.chart.outbound'),
[t('traffic.chart.today')]: Number(proxyInfo.todayTrafficOut),
[t('traffic.chart.history')]: Number(proxyInfo.historyTrafficOut),
},
]
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t('traffic.chart.title')}</CardTitle>
</CardHeader>
<CardContent>
<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))} />
<Tooltip labelClassName="font-mono" wrapperClassName="font-mono" formatter={(value) => formatBytes(Number(value))} />
<Legend />
<Bar dataKey="Today" fill="hsl(var(--chart-1))" radius={4} />
<Bar dataKey="History" fill="hsl(var(--chart-2))" radius={4} />
<Bar dataKey={t('traffic.chart.today')} fill="hsl(var(--chart-1))" radius={4} />
<Bar dataKey={t('traffic.chart.history')} fill="hsl(var(--chart-2))" radius={4} />
</BarChart>
</ResponsiveContainer>
</ChartContainer>
@@ -51,4 +54,3 @@ export function ProxyTrafficBarChart({ proxyInfo }:{ proxyInfo: ProxyInfo }) {
</Card>
)
}

View File

@@ -2,13 +2,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ProxyInfo } from "@/lib/pb/common"
import { formatBytes } from "@/lib/utils"
import { CloudDownload, CloudUpload } from "lucide-react"
import { useTranslation } from "react-i18next"
export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
const { t } = useTranslation()
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<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" />
</CardHeader>
<CardContent>
@@ -17,7 +20,7 @@ export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
</Card>
<Card>
<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" />
</CardHeader>
<CardContent>
@@ -26,7 +29,7 @@ export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
</Card>
<Card>
<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" />
</CardHeader>
<CardContent>
@@ -35,7 +38,7 @@ export function ProxyTrafficOverview({ proxyInfo }: { proxyInfo: ProxyInfo }) {
</Card>
<Card>
<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" />
</CardHeader>
<CardContent>

View File

@@ -9,15 +9,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
const chartConfig = {
trafficIn: {
label: "入站",
},
trafficOut: {
label: "出站",
},
} satisfies ChartConfig
import { useTranslation } from "react-i18next"
export function ProxyTrafficPieChart({ trafficIn, trafficOut, title, chartLabel }:
{ trafficIn: bigint,
@@ -25,6 +17,17 @@ export function ProxyTrafficPieChart({ trafficIn, trafficOut, title, chartLabel
title: 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 = [
{ type: "trafficIn", data: Number(trafficIn), fill: "hsl(var(--chart-1))" },
{ type: "trafficOut", data: Number(trafficOut), fill: "hsl(var(--chart-2))" }]
@@ -71,4 +74,3 @@ export function ProxyTrafficPieChart({ trafficIn, trafficOut, title, chartLabel
</Card>
)
}

View File

@@ -1,3 +1,5 @@
"use client"
import i18n from '@/lib/i18n'
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
@@ -27,17 +29,17 @@ export const CreateClientDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
const { toast } = useToast()
const handleNewClient = async () => {
toast({ title: t('已提交创建请求') })
toast({ title: t('client.create.submitting') })
try {
let resp = await newClient.mutateAsync({ clientId: clientID })
if (resp.status?.code !== RespCode.SUCCESS) {
toast({ title: t('创建客户端失败') })
toast({ title: t('client.create.error') })
return
}
toast({ title: t('创建客户端成功') })
toast({ title: t('client.create.success') })
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
} catch (error) {
toast({ title: t('创建客户端失败') })
toast({ title: t('client.create.error') })
}
}
@@ -45,19 +47,19 @@ export const CreateClientDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size={'sm'}>
{t('新建')}
{t('client.create.button')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('新建客户端')}</DialogTitle>
<DialogDescription>{t('创建新的客户端用于连接客户端ID必须唯一')}</DialogDescription>
<DialogTitle>{t('client.create.title')}</DialogTitle>
<DialogDescription>{t('client.create.description')}</DialogDescription>
</DialogHeader>
<Label>{t('客户端ID')}</Label>
<Label>{t('client.create.id')}</Label>
<Input className="mt-2" value={clientID} onChange={(e) => setClientID(e.target.value)} />
<DialogFooter>
<Button onClick={handleNewClient}>{t('创建')}</Button>
<Button onClick={handleNewClient}>{t('client.create.submit')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -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 {
Dialog,
@@ -34,8 +34,10 @@ import { ClientType } from '@/lib/pb/common'
import { ClientStatus, ClientStatus_Status } from '@/lib/pb/api_master'
import { startFrpc, stopFrpc } from '@/api/frp'
import { Badge } from '../ui/badge'
import { Label } from '@/components/ui/label'
import { ClientDetail } from '../base/client_detail'
import { Input } from '../ui/input'
import { useTranslation } from 'react-i18next'
export type ClientTableSchema = {
id: string
@@ -46,34 +48,45 @@ export type ClientTableSchema = {
config?: string
}
export interface TableMetaType extends TableMeta<ClientTableSchema> {
refetch: () => void
}
export const columns: ColumnDef<ClientTableSchema>[] = [
{
accessorKey: 'id',
header: 'ID(点击查看安装命令)',
header: function Header() {
const { t } = useTranslation()
return t('client.id')
},
cell: ({ row }) => {
return <ClientID client={row.original} />
},
},
{
accessorKey: 'status',
header: '是否配置',
header: function Header() {
const { t } = useTranslation()
return t('client.status')
},
cell: ({ row }) => {
const client = row.original
function Cell({ client }: { client: ClientTableSchema }) {
const { t } = useTranslation()
return (
<div className={`font-medium ${client.status === 'valid' ? 'text-green-500' : 'text-red-500'} min-w-12`}>
{
{
valid: '已配置',
invalid: '未配置',
}[client.status]
}
{client.status === 'valid' ? t('client.status_configured') : t('client.status_unconfigured')}
</div>
)
}
return <Cell client={row.original} />
},
},
{
accessorKey: 'info',
header: '运行信息/版本信息',
header: function Header() {
const { t } = useTranslation()
return t('client.info')
},
cell: ({ row }) => {
const client = row.original
return <ClientInfo client={client} />
@@ -81,7 +94,10 @@ export const columns: ColumnDef<ClientTableSchema>[] = [
},
{
accessorKey: 'secret',
header: '连接密钥(点击查看启动命令)',
header: function Header() {
const { t } = useTranslation()
return t('client.secret')
},
cell: ({ row }) => {
const client = row.original
return <ClientSecret client={client} />
@@ -91,39 +107,78 @@ export const columns: ColumnDef<ClientTableSchema>[] = [
id: 'action',
cell: ({ row, table }) => {
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 }) => {
const { t } = useTranslation()
const platformInfo = useStore($platformInfo)
if (!platformInfo) {
return (
<Button variant="link" className="px-0">
{client.id}
</Button>
)
}
return (
<Popover>
<PopoverTrigger asChild>
<div className="font-mono">{client.id}</div>
<Button variant="link" className="px-0 font-mono">
{client.id}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit overflow-auto max-w-72 max-h-72 text-nowrap">
<div></div>
<div>Linux安装到systemd</div>
<Input readOnly value={platformInfo === undefined
? '获取平台信息失败'
: LinuxInstallCommand('client', client, platformInfo)}></Input>
<div>Windows安装到系统服务</div>
<Input readOnly value={
platformInfo === undefined
? "获取平台信息失败"
: WindowsInstallCommand("client", client, platformInfo)
}>
</Input>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">{t('client.install.title')}</h4>
<p className="text-sm text-muted-foreground">{t('client.install.description')}</p>
</div>
<div className="grid gap-2">
<div className="grid grid-cols-2 items-center gap-4">
<Input
readOnly
value={WindowsInstallCommand('frpc', client, platformInfo)}
className="flex-1"
/>
<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>
</Popover>
)
}
export const ClientInfo = ({ client }: { client: ClientTableSchema }) => {
const clientsInfo = useQuery({
queryKey: ['getClientsStatus', [client.id]],
const { t } = useTranslation()
const { data: clientsStatus } = useQuery({
queryKey: ['clientsStatus', client.id],
queryFn: async () => {
return await getClientsStatus({
clientIds: [client.id],
@@ -133,61 +188,87 @@ export const ClientInfo = ({ client }: { client: ClientTableSchema }) => {
})
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) {
return statusText
}
if (info.status === ClientStatus_Status.ONLINE) {
statusText = '在线'
statusText = 'client.status_online'
if (client.stopped) {
statusText = '暂停'
statusText = 'client.status_pause'
}
} else if (info.status === ClientStatus_Status.OFFLINE) {
statusText = '离线'
statusText = 'client.status_offline'
} else if (info.status === ClientStatus_Status.ERROR) {
statusText = '错误'
statusText = 'client.status_error'
}
return statusText
}
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'
return (
<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`}>
{`${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>
{clientsInfo.data?.clients[client.id].version &&
<ClientDetail clientStatus={clientsInfo.data?.clients[client.id]} />
{clientsStatus?.clients[client.id].version &&
<ClientDetail clientStatus={clientsStatus?.clients[client.id]} />
}
</div>
)
}
export const ClientSecret = ({ client }: { client: ClientTableSchema }) => {
const [showSecrect, setShowSecrect] = useState<boolean>(false)
const fakeSecret = Array.from({ length: client.secret.length })
.map(() => '*')
.join('')
const { t } = useTranslation()
const platformInfo = useStore($platformInfo)
const { toast } = useToast()
if (!platformInfo) {
return (
<Button variant="link" className="px-0">
{client.secret}
</Button>
)
}
return (
<Popover>
<PopoverTrigger asChild>
<div
onMouseEnter={() => setShowSecrect(true)}
onMouseLeave={() => setShowSecrect(false)}
className="font-medium hover:rounded hover:bg-slate-100 p-2 font-mono whitespace-nowrap"
>
{showSecrect ? client.secret : fakeSecret}
<div className="group relative cursor-pointer inline-block font-mono text-nowrap">
<span className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{client.secret}
</span>
<span className="absolute inset-0 opacity-100 group-hover:opacity-0 transition-opacity duration-200">
{'*'.repeat(client.secret.length)}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-fit overflow-auto max-w-80">
<div>(<a className='text-blue-500' href='https://github.com/VaalaCat/frp-panel/releases'></a>)</div>
<div className="p-2 border rounded font-mono w-full break-all">
{platformInfo === undefined ? '获取平台信息失败' : ExecCommandStr('client', client, platformInfo)}
<PopoverContent className="w-[32rem] max-w-[95vw]">
<div className="grid gap-4">
<div className="space-y-2">
<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>
</PopoverContent>
</Popover>
@@ -200,52 +281,54 @@ export interface ClientItemProps {
}
export const ClientActions: React.FC<ClientItemProps> = ({ client, table }) => {
const { t } = useTranslation()
const { toast } = useToast()
const router = useRouter()
const platformInfo = useStore($platformInfo)
// placeholder for refetch
const refetchList = () => {}
const removeClient = useMutation({
mutationFn: deleteClient,
onSuccess: () => {
toast({ description: '删除成功' })
toast({ description: t('client.delete.success') })
refetchList()
},
onError: () => {
toast({ description: '删除失败' })
toast({ description: t('client.delete.failed') })
},
})
const stopClient = useMutation({
mutationFn: stopFrpc,
onSuccess: () => {
toast({ description: '停止成功' })
toast({ description: t('client.operation.stop_success') })
refetchList()
},
onError: () => {
toast({ description: '停止失败' })
toast({ description: t('client.operation.stop_failed') })
},
})
const startClient = useMutation({
mutationFn: startFrpc,
onSuccess: () => {
toast({ description: '启动成功' })
toast({ description: t('client.operation.start_success') })
refetchList()
},
onError: () => {
toast({ description: '启动失败' })
toast({ description: t('client.operation.start_failed') })
},
})
const createAndDownloadFile = (fileName: string, content: string) => {
var aTag = document.createElement('a');
var blob = new Blob([content]);
aTag.download = fileName;
aTag.href = URL.createObjectURL(blob);
aTag.click();
URL.revokeObjectURL(aTag.href);
const aTag = document.createElement('a')
const blob = new Blob([content])
aTag.download = fileName
aTag.href = URL.createObjectURL(blob)
aTag.click()
URL.revokeObjectURL(aTag.href)
}
return (
@@ -253,27 +336,27 @@ export const ClientActions: React.FC<ClientItemProps> = ({ client, table }) => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuLabel>{t('client.actions_menu.title')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
try {
if (platformInfo) {
navigator.clipboard.writeText(ExecCommandStr('client', client, platformInfo))
toast({ description: '复制成功如果复制不成功请点击ID字段手动复制' })
toast({ description: t('client.actions_menu.copy_success') })
} else {
toast({ description: '获取平台信息失败如果复制不成功请点击ID字段手动复制' })
toast({ description: t('client.actions_menu.copy_failed') })
}
} catch (error) {
toast({ description: '获取平台信息失败如果复制不成功请点击ID字段手动复制' })
toast({ description: t('client.actions_menu.copy_failed') })
}
}}
>
{t('client.actions_menu.copy_command')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -281,61 +364,68 @@ export const ClientActions: React.FC<ClientItemProps> = ({ client, table }) => {
router.push({ pathname: '/clientedit', query: { clientID: client.id } })
}}
>
{t('client.actions_menu.edit_config')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
try {
if (platformInfo) {
createAndDownloadFile(`.env`, ClientEnvFile(client, platformInfo))
createAndDownloadFile('.env', ClientEnvFile(client, platformInfo))
}
}
catch (error) {
toast({ description: '获取平台信息失败' })
} catch (error) {
toast({ description: t('client.actions_menu.download_failed') })
}
}}
>
{t('client.actions_menu.download_config')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
router.push({ pathname: '/streamlog', query: { clientID: client.id, clientType: ClientType.FRPC.toString() } })
}}
>
{t('client.actions_menu.realtime_log')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
router.push({ pathname: '/console', query: { clientID: client.id, clientType: ClientType.FRPC.toString() } })
}}
>
{t('client.actions_menu.remote_terminal')}
</DropdownMenuItem>
{!client.stopped && <DropdownMenuItem className="text-destructive" onClick={() => stopClient.mutate({ clientId: client.id })}></DropdownMenuItem>}
{client.stopped && <DropdownMenuItem onClick={() => startClient.mutate({ clientId: client.id })}></DropdownMenuItem>}
{!client.stopped && (
<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>
<DropdownMenuItem className="text-destructive"></DropdownMenuItem>
<DropdownMenuItem className="text-destructive">{t('client.actions_menu.delete')}</DropdownMenuItem>
</DialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>?</DialogTitle>
<DialogTitle>{t('client.delete.title')}</DialogTitle>
<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">
{t('client.delete.warning')}
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="submit" onClick={() => removeClient.mutate({ clientId: client.id })}>
{t('client.delete.confirm')}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog >
</Dialog>
)
}

View File

@@ -1,3 +1,5 @@
"use client"
import React, { useEffect } from 'react'
import { useState } from 'react'
import { Label } from '@radix-ui/react-label'
@@ -12,15 +14,18 @@ import { ClientConfig } from '@/types/client'
import { TypedProxyConfig } from '@/types/proxy'
import { ClientSelector } from '../base/client-selector'
import { ServerSelector } from '../base/server-selector'
import { useTranslation } from 'react-i18next'
export interface FRPCFormCardProps {
clientID?: string
serverID?: string
}
export const FRPCFormCard: React.FC<FRPCFormCardProps> = ({
clientID: defaultClientID,
serverID: defaultServerID,
}: FRPCFormCardProps) => {
const { t } = useTranslation()
const [advanceMode, setAdvanceMode] = useState<boolean>(false)
const [clientID, setClientID] = useState<string | undefined>()
const [serverID, setServerID] = useState<string | undefined>()
@@ -78,31 +83,31 @@ export const FRPCFormCard: React.FC<FRPCFormCardProps> = ({
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t('frpc.form.title')}</CardTitle>
<CardDescription>
<div></div>
<div></div>
<div>{t('frpc.form.description.warning')}</div>
<div>{t('frpc.form.description.instruction')}</div>
</CardDescription>
</CardHeader>
<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">
<p className="text-sm font-medium leading-none"></p>
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm font-medium leading-none">{t('frpc.form.advanced.title')}</p>
<p className="text-sm text-muted-foreground">{t('frpc.form.advanced.description')}</p>
</div>
<Switch onCheckedChange={setAdvanceMode} />
</div>
<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} />
<Label className="text-sm font-medium"></Label>
<Label className="text-sm font-medium">{t('frpc.form.client')}</Label>
<ClientSelector clientID={clientID} setClientID={setClientID} />
</div>
{clientID && !advanceMode && <div className='flex flex-col w-full pt-2 space-y-2'>
<Label className="text-sm font-medium"> {clientID} </Label>
<p className="text-sm text-muted-foreground"></p>
<Label className="text-sm font-medium">{t('frpc.form.comment.title', { id: clientID })}</Label>
<p className="text-sm text-muted-foreground">{t('frpc.form.comment.hint')}</p>
<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>}
{clientID && serverID && !advanceMode && <FRPCForm
client={client?.client}

View File

@@ -7,8 +7,10 @@ import { Button } from '@/components/ui/button'
import { updateFRPC } from '@/api/frp'
import { useToast } from '@/components/ui/use-toast'
import { RespCode } from '@/lib/pb/common'
import { useTranslation } from 'react-i18next'
export const FRPCEditor: React.FC<FRPCFormProps> = ({ clientID, serverID, client, refetchClient }) => {
const { t } = useTranslation()
const { toast } = useToast()
const [configContent, setConfigContent] = useState<string>('{}')
@@ -26,12 +28,12 @@ export const FRPCEditor: React.FC<FRPCFormProps> = ({ clientID, serverID, client
comment: clientComment,
})
if (res.status?.code !== RespCode.SUCCESS) {
toast({ title: '更新失败' })
toast({ title: t('client.operation.update_failed') })
return
}
toast({ title: '更新成功' })
toast({ title: t('client.operation.update_success') })
} 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 (
<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
key={client?.comment}
placeholder="备注"
placeholder={t('client.editor.comment_placeholder')}
id="message"
defaultValue={client?.comment}
onChange={(e) => setClientComment(e.target.value)}
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">
proxies和visitors字段
{t('client.editor.config_description')}
</p>
<Textarea
key={configContent}
placeholder="配置文件内容"
placeholder={t('client.editor.config_placeholder')}
id="message"
defaultValue={configContent}
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">
<Button size="sm" onClick={handleSubmit}>
{t('common.submit')}
</Button>
{/* <Button variant="outline" size="sm" onClick={async () => {
await refetchClient()

View File

@@ -16,6 +16,7 @@ import { QueryObserverResult, RefetchOptions, useMutation } from '@tanstack/reac
import { updateFRPC } from '@/api/frp'
import { Card, CardContent } from '@/components/ui/card'
import { GetClientResponse } from '@/lib/pb/api_client'
import { useTranslation } from 'react-i18next'
export interface FRPCFormProps {
clientID: string
@@ -28,9 +29,11 @@ export interface FRPCFormProps {
}
export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client, refetchClient, clientProxyConfigs, setClientProxyConfigs }) => {
const { t } = useTranslation()
const [proxyType, setProxyType] = useState<ProxyType>('http')
const [proxyName, setProxyName] = useState<string | undefined>()
const { toast } = useToast()
const handleTypeChange = (value: string) => {
setProxyType(value as ProxyType)
}
@@ -40,7 +43,10 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
if (!proxyName) return
if (!proxyType) return
if (clientProxyConfigs.findIndex((proxy) => proxy.name === proxyName) !== -1) {
toast({ title: '创建隧道状态', description: '名称重复' })
toast({
title: t('proxy.status.create'),
description: t('proxy.status.name_exists')
})
return
}
const newProxy = {
@@ -70,10 +76,16 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
clientId: clientID,
})
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) {
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>
<PopoverTrigger asChild>
<Button className="my-2"></Button>
<Button className="my-2">{t('proxy.form.add')}</Button>
</PopoverTrigger>
<PopoverContent>
<Label className="text-sm font-medium"></Label>
<Label className="text-sm font-medium">{t('proxy.form.name')}</Label>
<Input
onChange={(e) => {
setProxyName(e.target.value)
}}
/>
<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">
<SelectValue placeholder="类型" />
<SelectValue placeholder={t('proxy.form.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="tcp">tcp</SelectItem>
<SelectItem value="udp">udp</SelectItem>
<SelectItem value="stcp">stcp</SelectItem>
<SelectItem value="http">{t('proxy.type.http')}</SelectItem>
<SelectItem value="tcp">{t('proxy.type.tcp')}</SelectItem>
<SelectItem value="udp">{t('proxy.type.udp')}</SelectItem>
<SelectItem value="stcp">{t('proxy.type.stcp')}</SelectItem>
</SelectContent>
</Select>
<Button variant={'outline'} onClick={handleAddProxy}>
{t('proxy.form.confirm')}
</Button>
</PopoverContent>
</Popover>
<Accordion type="single" defaultValue="proxies" collapsible key={clientID + serverID + client}>
<AccordionItem value="proxies">
<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>
<AccordionContent className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{clientProxyConfigs.map((item) => {
@@ -121,17 +136,17 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
<Accordion type="single" collapsible>
<AccordionItem value={item.name}>
<AccordionHeader className="flex flex-row justify-between">
<div>{item.name}</div>
<div>{t('proxy.form.tunnel_name')}: {item.name}</div>
<Button
variant={'outline'}
onClick={() => {
handleDeleteProxy(item.name)
}}
>
{t('proxy.form.delete')}
</Button>
</AccordionHeader>
<AccordionTrigger>:{item.type}</AccordionTrigger>
<AccordionTrigger>{t('proxy.form.type_label', { type: item.type })}</AccordionTrigger>
<AccordionContent>
{item.type === 'tcp' && serverID && clientID && (
<TCPProxyForm
@@ -183,73 +198,6 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
})}
</AccordionContent>
</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>
<Button
className="mt-2"
@@ -257,7 +205,7 @@ export const FRPCForm: React.FC<FRPCFormProps> = ({ clientID, serverID, client,
handleUpdate()
}}
>
{t('proxy.form.submit')}
</Button>
</>
)

View File

@@ -8,13 +8,15 @@ import { Control, FieldValues, useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useStore } from '@nanostores/react'
import { YesIcon } from '@/components/ui/icon'
import { Label } from '@/components/ui/label'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { getServer } from '@/api/server'
import { ServerConfig } from '@/types/server'
import { ArrowRightIcon } from 'lucide-react'
export const TCPConfigSchema = z.object({
remotePort: ZodPortSchema,
localIP: ZodStringSchema.default('127.0.0.1'),
@@ -59,13 +61,15 @@ const IPField = ({
label: string
defaultValue?: string
}) => {
const { t } = useTranslation()
return (
<FormField
name={name}
control={control}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormLabel>{t(label)}</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1" {...field} />
</FormControl>
@@ -88,13 +92,15 @@ const PortField = ({
label: string
defaultValue?: number
}) => {
const { t } = useTranslation()
return (
<FormField
name={name}
control={control}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormLabel>{t(label)}</FormLabel>
<FormControl>
<Input placeholder="8080" {...field} />
</FormControl>
@@ -117,13 +123,15 @@ const SecretKeyField = ({
label: string
defaultValue?: string
}) => {
const { t } = useTranslation()
return (
<FormField
name={name}
control={control}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormLabel>{t(label)}</FormLabel>
<FormControl>
<Input placeholder="secret key" {...field} />
</FormControl>
@@ -168,6 +176,8 @@ export const TCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
}, 3000)
}
const { t } = useTranslation()
const { data: server } = useQuery({
queryKey: ['getServer', serverID],
queryFn: () => {
@@ -178,7 +188,7 @@ export const TCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
return (
<Form {...form}>
<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 && (
<div className="flex items-center space-x-2">
<Input
@@ -197,24 +207,24 @@ export const TCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
<PortField
control={form.control}
name="localPort"
label="本地端口"
label="proxy.form.local_port"
defaultValue={defaultConfig?.localPort || 1234}
/>
<IPField
control={form.control}
name="localIP"
label="转发地址"
label="proxy.form.local_ip"
defaultValue={defaultConfig?.localIP || '127.0.0.1'}
/>
<PortField
control={form.control}
name="remotePort"
label="远端端口"
label="proxy.form.remote_port"
defaultValue={defaultConfig?.remotePort || 4321}
/>
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
{t('proxy.form.save_changes')}
</Button>
</form>
</Form>
@@ -254,6 +264,8 @@ export const STCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
}, 3000)
}
const { t } = useTranslation()
const { data: server } = useQuery({
queryKey: ['getServer', serverID],
queryFn: () => {
@@ -267,19 +279,19 @@ export const STCPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
<PortField
control={form.control}
name="localPort"
label="本地端口"
label="proxy.form.local_port"
defaultValue={defaultConfig?.localPort || 1234}
/>
<IPField
control={form.control}
name="localIP"
label="转发地址"
label="proxy.form.local_ip"
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'}>
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
{t('proxy.form.save_changes')}
</Button>
</form>
</Form>
@@ -318,6 +330,8 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
}, 3000)
}
const { t } = useTranslation()
const { data: server } = useQuery({
queryKey: ['getServer', serverID],
queryFn: () => {
@@ -328,7 +342,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
return (
<Form {...form}>
<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">
{`${server?.server?.ip}:${(defaultProxyConfig as UDPProxyConfig).remotePort} -> ${
defaultProxyConfig?.localIP
@@ -339,7 +353,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
name="localPort"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormLabel>{t('proxy.form.local_port')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
@@ -353,7 +367,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
name="localIP"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormLabel>{t('proxy.form.local_ip')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -367,7 +381,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
name="remotePort"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormLabel>{t('proxy.form.remote_port')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
@@ -378,7 +392,7 @@ export const UDPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, def
/>
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
{t('proxy.form.save_changes')}
</Button>
</form>
</Form>
@@ -418,6 +432,8 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
}, 3000)
}
const { t } = useTranslation()
const { data: server } = useQuery({
queryKey: ['getServer', serverID],
queryFn: () => {
@@ -434,7 +450,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
return (
<Form {...form}>
<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">
{`http://${(defaultProxyConfig as HTTPProxyConfig).subdomain}.${serverConfig?.subDomainHost}:${
serverConfig?.vhostHTTPPort
@@ -445,7 +461,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
name="localPort"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormLabel>{t('proxy.form.local_port')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
@@ -459,7 +475,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
name="localIP"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormLabel>{t('proxy.form.local_ip')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -473,7 +489,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
name="subDomain"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormLabel>{t('proxy.form.subdomain')}</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
@@ -484,7 +500,7 @@ export const HTTPProxyForm: React.FC<ProxyFormProps> = ({ serverID, clientID, de
/>
<Button type="submit" disabled={isSaveDisabled} variant={'outline'}>
<YesIcon className={`mr-2 h-4 w-4 ${isSaveDisabled ? '' : 'hidden'}`}></YesIcon>
{t('proxy.form.save_changes')}
</Button>
</form>
</Form>

View File

@@ -8,6 +8,7 @@ import { FRPSEditor } from './frps_editor'
import FRPSForm from './frps_form'
import { useSearchParams } from 'next/navigation'
import { ServerSelector } from '../base/server-selector'
import { useTranslation } from 'react-i18next';
export interface FRPSFormCardProps {
serverID?: string
@@ -23,6 +24,7 @@ export const FRPSFormCard: React.FC<FRPSFormCardProps> = ({ serverID: defaultSer
return getServer({ serverId: serverID })
},
})
const { t } = useTranslation();
useEffect(() => {
if (defaultServerID) {
@@ -43,27 +45,27 @@ export const FRPSFormCard: React.FC<FRPSFormCardProps> = ({ serverID: defaultSer
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t('server.configuration')}</CardTitle>
<CardDescription>
<div>
<a className='text-red-600'>退</a>
<br />使docker容器且启动命令中包含了 --restart=unless-stopped --restart=always
<br />使systemd安装也无需担心
{t('server.warning.title')}
<br />{t('server.warning.dockerHint')}
<br />{t('server.warning.systemdHint')}
</div>
<div>
Frps服务
{t('server.selectHint')}
</div></CardDescription>
</CardHeader>
<CardContent>
<div className=" flex items-center space-x-4 rounded-md border p-4">
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none"></p>
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm font-medium leading-none">{t('server.advancedMode.title')}</p>
<p className="text-sm text-muted-foreground">{t('server.advancedMode.description')}</p>
</div>
<Switch onCheckedChange={setAdvanceMode} />
</div>
<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} />
</div>
{serverID && server && server.server && !advanceMode && <FRPSForm key={serverID} serverID={serverID} server={server.server} />}

View File

@@ -8,8 +8,10 @@ import { getServer } from '@/api/server'
import { useEffect, useState } from 'react'
import { updateFRPS } from '@/api/frp'
import { RespCode } from '@/lib/pb/common'
import { useTranslation } from 'react-i18next'
export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
const { t } = useTranslation()
const { toast } = useToast()
const { data: serverResp, refetch: refetchServer } = useQuery({
queryKey: ['getServer', serverID],
@@ -32,12 +34,12 @@ export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
comment: serverComment,
})
if (res.status?.code !== RespCode.SUCCESS) {
toast({ title: '更新失败' })
toast({ title: t('server.operation.update_failed') })
return
}
toast({ title: '更新成功' })
toast({ title: t('server.operation.update_success') })
} catch (error) {
toast({ title: '更新失败' })
toast({ title: t('server.operation.update_failed') })
}
refetchServer()
}
@@ -76,20 +78,20 @@ export const FRPSEditor: React.FC<FRPSFormProps> = ({ server, serverID }) => {
return (
<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
key={serverResp?.server?.comment}
placeholder="备注"
placeholder={t('server.editor.comment_placeholder')}
id="message"
defaultValue={serverResp?.server?.comment}
onChange={(e) => setServerComment(e.target.value)}
className="h-12"
/>
<Label className="text-sm font-medium"> {serverID} `frps.json`</Label>
<p className="text-sm text-muted-foreground">IP等字段</p>
<Label className="text-sm font-medium">{t('server.editor.config_title', { id: serverID })}</Label>
<p className="text-sm text-muted-foreground">{t('server.editor.config_description')}</p>
<Textarea
key={configContent}
placeholder="配置文件内容"
placeholder={t('server.editor.config_placeholder')}
id="message"
defaultValue={configContent}
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">
<Button size="sm" onClick={handleSubmit}>
{t('common.submit')}
</Button>
</div>
</div>

View File

@@ -12,6 +12,7 @@ import { updateFRPS } from '@/api/frp'
import { useMutation } from '@tanstack/react-query'
import { useToast } from '@/components/ui/use-toast'
import { Label } from '@radix-ui/react-label'
import { useTranslation } from 'react-i18next'
const ServerConfigSchema = z.object({
bindAddr: ZodIPSchema.default('0.0.0.0').optional(),
@@ -30,6 +31,7 @@ export interface FRPSFormProps {
}
const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
const { t } = useTranslation()
const form = useForm<z.infer<typeof ServerConfigZodSchema>>({
resolver: zodResolver(ServerConfigZodSchema),
})
@@ -59,21 +61,24 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
),
})
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,
})
} catch (error) {
console.error(error)
toast({ title: '修改服务端状态', description: '创建失败' })
toast({
title: t('server.operation.update_title'),
description: t('server.operation.update_failed')
})
}
}
return (
<div className="flex flex-col w-full pt-2">
<Label className="text-sm font-medium"> {serverID} </Label>
<p className="text-sm text-muted-foreground"></p>
<Label className="text-sm font-medium">{t('server.form.comment_title', { id: serverID })}</Label>
<p className="text-sm text-muted-foreground">{t('server.form.comment_hint')}</p>
<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>
{serverID && (
<Form {...form}>
@@ -83,7 +88,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
name="publicHost"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel>{t('server.form.public_host')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -97,7 +102,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
name="bindPort"
render={({ field }) => (
<FormItem>
<FormLabel>FRPs </FormLabel>
<FormLabel>{t('server.form.bind_port')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
@@ -111,7 +116,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
name="bindAddr"
render={({ field }) => (
<FormItem>
<FormLabel>FRPs </FormLabel>
<FormLabel>{t('server.form.bind_addr')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -125,7 +130,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
name="proxyBindAddr"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel>{t('server.form.proxy_bind_addr')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -138,7 +143,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
name="vhostHTTPPort"
render={({ field }) => (
<FormItem>
<FormLabel>HTTP </FormLabel>
<FormLabel>{t('server.form.vhost_http_port')}</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
@@ -151,7 +156,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
name="subDomainHost"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormLabel>{t('server.form.subdomain_host')}</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
@@ -159,7 +164,7 @@ const FRPSForm: React.FC<FRPSFormProps> = ({ serverID, server }) => {
</FormItem>
)}
/>
<Button type="submit"></Button>
<Button type="submit">{t('common.submit')}</Button>
</form>
</Form>
)}

View File

@@ -1,3 +1,5 @@
"use client"
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { initServer } from '@/api/server'
@@ -15,8 +17,10 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { useTranslation } from 'react-i18next'
export const CreateServerDialog = ({refetchTrigger}: {refetchTrigger?: (randStr: string) => void}) => {
const { t } = useTranslation()
const [serverID, setServerID] = useState<string | undefined>()
const [serverIP, setServerIP] = useState<string | undefined>()
const newServer = useMutation({
@@ -25,17 +29,17 @@ export const CreateServerDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
const { toast } = useToast()
const handleNewServer = async () => {
toast({ title: '已提交创建请求' })
toast({ title: t('server.create.submitting') })
try {
let resp = await newServer.mutateAsync({ serverId: serverID, serverIp: serverIP })
if (resp.status?.code !== RespCode.SUCCESS) {
toast({ title: '创建服务端失败' })
toast({ title: t('server.create.error') })
return
}
toast({ title: '创建服务端成功' })
toast({ title: t('server.create.success') })
refetchTrigger && refetchTrigger(JSON.stringify(Math.random()))
} catch (error) {
toast({ title: '创建服务端失败' })
toast({ title: t('server.create.error') })
}
}
@@ -43,21 +47,21 @@ export const CreateServerDialog = ({refetchTrigger}: {refetchTrigger?: (randStr:
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size={'sm'}>
{t('server.create.button')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>ID必须唯一</DialogDescription>
<DialogTitle>{t('server.create.title')}</DialogTitle>
<DialogDescription>{t('server.create.description')}</DialogDescription>
</DialogHeader>
<Label>ID</Label>
<Label>{t('server.create.id')}</Label>
<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)} />
<DialogFooter>
<Button onClick={handleNewServer}></Button>
<Button onClick={handleNewServer}>{t('server.create.submit')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -34,11 +34,14 @@ import { ClientType } from '@/lib/pb/common'
import { ClientStatus, ClientStatus_Status } from '@/lib/pb/api_master'
import { Badge } from '../ui/badge'
import { ClientDetail } from '../base/client_detail'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
export type ServerTableSchema = {
id: string
status: 'invalid' | 'valid'
secret: string
stopped: boolean
info?: string
ip: string
config?: string
@@ -47,76 +50,130 @@ export type ServerTableSchema = {
export const columns: ColumnDef<ServerTableSchema>[] = [
{
accessorKey: 'id',
header: 'ID(点击查看安装命令)',
header: function Header() {
const { t } = useTranslation()
return t('server.id')
},
cell: ({ row }) => {
return <ServerID server={row.original} />
},
},
{
accessorKey: 'status',
header: '是否配置',
header: function Header() {
const { t } = useTranslation()
return t('server.status')
},
cell: ({ row }) => {
const Server = row.original
function Cell({ server }: { server: ServerTableSchema }) {
const { t } = useTranslation()
return (
<div className={`font-mono ${Server.status === 'valid' ? 'text-green-500' : 'text-red-500'} min-w-12`}>
{
{
valid: '已配置',
invalid: '未配置',
}[Server.status]
}
<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')}
</div>
)
}
return <Cell server={row.original} />
},
},
{
accessorKey: 'info',
header: '运行信息/版本信息',
header: function Header() {
const { t } = useTranslation()
return t('server.info')
},
cell: ({ row }) => {
const Server = row.original
return <ServerInfo server={Server} />
const server = row.original
return <ServerInfo server={server} />
},
},
{
accessorKey: 'ip',
header: 'IP/域名',
header: function Header() {
const { t } = useTranslation()
return t('server.ip')
},
cell: ({ row }) => {
const Server = row.original
return <div className="font-mono">{Server.ip}</div>
return row.original.ip
},
},
{
accessorKey: 'secret',
header: '连接密钥(点击查看启动命令)',
header: function Header() {
const { t } = useTranslation()
return t('server.secret')
},
cell: ({ row }) => {
const Server = row.original
return <ServerSecret server={Server} />
const server = row.original
return <ServerSecret server={server} />
},
},
{
id: 'action',
cell: ({ row, table }) => {
const Server = row.original
return <ServerActions server={Server} table={table} />
const server = row.original
return <ServerActions server={server} table={table as Table<ServerTableSchema>} />
},
},
]
export const ServerID = ({ server }: { server: ServerTableSchema }) => {
const { t } = useTranslation()
const platformInfo = useStore($platformInfo)
if (!platformInfo) {
return (
<Button variant="link" className="px-0">
{server.id}
</Button>
)
}
return (
<Popover>
<PopoverTrigger asChild>
<div className="font-mono">{server.id}</div>
<Button variant="link" className="px-0 font-mono">
{server.id}
</Button>
</PopoverTrigger>
<PopoverContent className="w-fit overflow-auto max-w-72 max-h-72">
<div>Linux安装到systemd</div>
<div className="p-2 border rounded font-mono w-fit">
{platformInfo === undefined ? '获取平台信息失败' : LinuxInstallCommand('server', server, platformInfo)}
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">{t('server.install.title')}</h4>
<p className="text-sm text-muted-foreground">{t('server.install.description')}</p>
</div>
<div className="grid gap-2">
<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>Windows安装到系统服务</div>
<div className="p-2 border rounded font-mono w-fit">
{platformInfo === undefined ? '获取平台信息失败' : WindowsInstallCommand('server', server, platformInfo)}
</div>
</PopoverContent>
</Popover>
@@ -124,8 +181,9 @@ export const ServerID = ({ server }: { server: ServerTableSchema }) => {
}
export const ServerInfo = ({ server }: { server: ServerTableSchema }) => {
const clientsInfo = useQuery({
queryKey: ['getClientsStatus', [server.id]],
const { t } = useTranslation()
const { data: clientsStatus } = useQuery({
queryKey: ['clientsStatus', server.id],
queryFn: async () => {
return await getClientsStatus({
clientIds: [server.id],
@@ -135,58 +193,87 @@ export const ServerInfo = ({ server }: { server: ServerTableSchema }) => {
})
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) {
return statusText
}
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) {
statusText = '离线'
statusText = 'server.status_offline'
} else if (info.status === ClientStatus_Status.ERROR) {
statusText = '错误'
statusText = 'server.status_error'
}
return statusText
}
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 (
<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`}>
{`${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>
{clientsInfo.data?.clients[server.id].version &&
<ClientDetail clientStatus={clientsInfo.data?.clients[server.id]} />
{clientsStatus?.clients[server.id]?.version &&
<ClientDetail clientStatus={clientsStatus?.clients[server.id]} />
}
</div>
)
}
export const ServerSecret = ({ server }: { server: ServerTableSchema }) => {
const [showSecrect, setShowSecrect] = useState<boolean>(false)
const fakeSecret = Array.from({ length: server.secret.length })
.map(() => '*')
.join('')
const { toast } = useToast()
const { t } = useTranslation()
const platformInfo = useStore($platformInfo)
if (!platformInfo) {
return (
<Button variant="link" className="px-0">
{server.secret}
</Button>
)
}
return (
<Popover>
<PopoverTrigger asChild>
<div
onMouseEnter={() => setShowSecrect(true)}
onMouseLeave={() => setShowSecrect(false)}
className="font-medium hover:rounded hover:bg-slate-100 p-2 font-mono whitespace-nowrap"
>
{showSecrect ? server.secret : fakeSecret}
<div className="group relative cursor-pointer inline-block font-mono text-nowrap">
<span className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{server.secret}
</span>
<span className="absolute inset-0 opacity-100 group-hover:opacity-0 transition-opacity duration-200">
{'*'.repeat(server.secret.length)}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-fit overflow-auto max-w-48">
<div>(<a className='text-blue-500' href='https://github.com/VaalaCat/frp-panel/releases'></a>)</div>
<div className="p-2 border rounded font-mono w-full break-all">
{platformInfo === undefined ? '获取平台信息失败' : ExecCommandStr('server', server, platformInfo)}
<PopoverContent className="w-[32rem] max-w-[95vw]">
<div className="grid gap-4">
<div className="space-y-2">
<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>
</PopoverContent>
</Popover>
@@ -199,30 +286,31 @@ export interface ServerItemProps {
}
export const ServerActions: React.FC<ServerItemProps> = ({ server, table }) => {
const { t } = useTranslation()
const { toast } = useToast()
const router = useRouter()
const platformInfo = useStore($platformInfo)
const refetchList = () => { }
const refetchList = () => {}
const removeServer = useMutation({
mutationFn: deleteServer,
onSuccess: () => {
toast({ description: '删除成功' })
toast({ description: t('server.delete.success') })
refetchList()
},
onError: () => {
toast({ description: '删除失败' })
toast({ description: t('server.delete.failed') })
},
})
const createAndDownloadFile = (fileName: string, content: string) => {
var aTag = document.createElement('a');
var blob = new Blob([content]);
aTag.download = fileName;
aTag.href = URL.createObjectURL(blob);
aTag.click();
URL.revokeObjectURL(aTag.href);
const aTag = document.createElement('a')
const blob = new Blob([content])
aTag.download = fileName
aTag.href = URL.createObjectURL(blob)
aTag.click()
URL.revokeObjectURL(aTag.href)
}
return (
@@ -230,88 +318,82 @@ export const ServerActions: React.FC<ServerItemProps> = ({ server, table }) => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuLabel>{t('server.actions_menu.title')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
try {
if (platformInfo) {
navigator.clipboard.writeText(ExecCommandStr('server', server, platformInfo))
toast({ description: '复制成功如果复制不成功请点击ID字段手动复制' })
toast({ description: t('server.actions_menu.copy_success') })
} else {
toast({ description: '获取平台信息失败如果复制不成功请点击ID字段手动复制' })
toast({ description: t('server.actions_menu.copy_failed') })
}
} catch (error) {
toast({ description: '获取平台信息失败如果复制不成功请点击ID字段手动复制' })
toast({ description: t('server.actions_menu.copy_failed') })
}
}}
>
{t('server.actions_menu.copy_command')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
router.push({
pathname: '/serveredit',
query: {
serverID: server.id,
},
})
router.push({ pathname: '/serveredit', query: { serverID: server.id } })
}}
>
{t('server.actions_menu.edit_config')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
try {
if (platformInfo) {
createAndDownloadFile(`.env`, ClientEnvFile(server, platformInfo))
createAndDownloadFile('.env', ClientEnvFile(server, platformInfo))
}
}
catch (error) {
toast({ description: '获取平台信息失败' })
} catch (error) {
toast({ description: t('server.actions_menu.download_failed') })
}
}}
>
{t('server.actions_menu.download_config')}
</DropdownMenuItem>
<DropdownMenuItem
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
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>
<DialogTrigger asChild>
<DropdownMenuItem className="text-destructive"></DropdownMenuItem>
<DropdownMenuItem className="text-destructive">{t('server.actions_menu.delete')}</DropdownMenuItem>
</DialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>?</DialogTitle>
<DialogTitle>{t('server.delete.title')}</DialogTitle>
<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">
{t('server.delete.warning')}
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="submit" onClick={() => removeServer.mutate({ serverId: server.id })}>
{t('server.delete.confirm')}
</Button>
</DialogClose>
</DialogFooter>

View File

@@ -1,19 +1,55 @@
import { Button } from './ui/button'
import { useStore } from '@nanostores/react'
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 { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { getPlatformInfo } from '@/api/platform'
import { useTranslation } from 'react-i18next'
import { LanguageSwitcher } from './language-switcher'
export const Header = () => {
return (<></>)
export const Header = ({ title }: { title?: string }) => {
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 = () => {
const router = useRouter()
const userInfo = useStore($userInfo)
const { t } = useTranslation()
const platformInfo = useQuery({
queryKey: ['platformInfo'],
@@ -31,18 +67,19 @@ export const RegisterAndLogin = () => {
useEffect(() => {
$userInfo.set(userInfoQuery.data?.userInfo)
$statusOnline.set(!!userInfoQuery.data?.userInfo)
}, [userInfoQuery])
return (
<>
{!userInfo && (
<Button variant={'ghost'} onClick={() => router.push('/login')}>
<Button variant="ghost" size="sm" onClick={() => router.push('/login')}>
{t('common.login')}
</Button>
)}
{!userInfo && (
<Button variant={'ghost'} onClick={() => router.push('/register')}>
<Button variant="ghost" size="sm" onClick={() => router.push('/register')}>
{t('common.register')}
</Button>
)}
</>

View 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>
)
}

View File

@@ -6,7 +6,7 @@ import {
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
export function RootLayout({
children,
@@ -20,21 +20,22 @@ export function RootLayout({
mainHeader?: React.ReactNode
}) {
const { open, isMobile } = useSidebar()
return (
<>
<AppSidebar footer={sidebarFooter} >{sidebarItems} </AppSidebar>
<AppSidebar footer={sidebarFooter}>{sidebarItems}</AppSidebar>
<SidebarInset>
<div className={cn("relative flex flex-1 flex-col overflow-hidden transition-all",
isMobile && "w-[100dvw]",
!isMobile && open && "w-[calc(100dvw-var(--sidebar-width))]",
!isMobile && !open && "w-[calc(100dvw-var(--sidebar-width-icon))]"
)}>
<header className="flex flex-row h-12 items-center gap-2 w-full">
<div className="flex flex-row items-center gap-2 px-4 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">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{mainHeader}
</div>
{mainHeader}
</header>
<div id="main-content"
className="h-[calc(100dvh_-_48px)] overflow-auto w-full pb-4 px-4 pt-2">

View File

@@ -2,7 +2,7 @@ import { ZodStringSchema } from '@/lib/consts'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
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 { login } from '@/api/auth'
import { Button } from './ui/button'
@@ -14,13 +14,15 @@ import { useState } from 'react'
import { useToast } from './ui/use-toast'
import { RespCode } from '@/lib/pb/common'
import { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next';
export const LoginSchema = z.object({
username: ZodStringSchema,
password: ZodStringSchema,
})
export const LoginComponent = () => {
export function LoginComponent() {
const { t } = useTranslation();
const form = useForm<z.infer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema),
})
@@ -30,21 +32,21 @@ export const LoginComponent = () => {
const [loginAlert, setLoginAlert] = useState(false)
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
toast({ title: '登录中,请稍候' })
toast({ title: t('auth.loggingIn') })
try {
const res = await login({ ...values })
if (res.status?.code === RespCode.SUCCESS) {
toast({ title: '登录成功,正在跳转到首页' })
toast({ title: t('auth.loginSuccess') })
setTimeout(() => {
router.push('/')
}, 3000)
setLoginAlert(false)
} else {
toast({ title: '登录失败' })
toast({ title: t('auth.loginFailed') })
setLoginAlert(true)
}
} catch (e) {
toast({ title: '登录失败' })
toast({ title: t('auth.loginFailed') })
console.log('login error', e)
setLoginAlert(true)
}
@@ -59,8 +61,9 @@ export const LoginComponent = () => {
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.usernamePlaceholder')}</FormLabel>
<FormControl>
<Input type="text" placeholder="用户名" {...field} />
<Input type="text" placeholder={t('auth.usernamePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -71,8 +74,9 @@ export const LoginComponent = () => {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.password')}</FormLabel>
<FormControl>
<Input type="password" placeholder="密码" {...field} />
<Input type="password" placeholder={t('auth.passwordPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -81,11 +85,13 @@ export const LoginComponent = () => {
{loginAlert && (
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
<AlertTitle>{t('auth.error')}</AlertTitle>
<AlertDescription>{t('auth.loginFailed')}</AlertDescription>
</Alert>
)}
<Button type="submit"></Button>
<Button className="w-full" type="submit">
{t('common.login')}
</Button>
</form>
</Form>
</div>

View File

@@ -24,12 +24,14 @@ import { Avatar } from "./ui/avatar"
import { UserAvatar } from "./base/avatar"
import { $token, $userInfo } from "@/store/user"
import { logout } from "@/api/auth"
import { useTranslation } from 'react-i18next';
export function NavUser({
user,
}: {
export interface NavUserProps {
user: User
}) {
}
export function NavUser({ user }: NavUserProps) {
const { t } = useTranslation();
const { isMobile } = useSidebar()
return (
@@ -45,8 +47,8 @@ export function NavUser({
<UserAvatar className="w-8 h-8" userInfo={user} />
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.userName}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-semibold">{user?.userName}</span>
<span className="truncate text-xs">{user?.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
@@ -63,8 +65,8 @@ export function NavUser({
<UserAvatar className="w-8 h-8" userInfo={user} />
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.userName}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-semibold">{user?.userName}</span>
<span className="truncate text-xs">{user?.email}</span>
</div>
</div>
</DropdownMenuLabel>
@@ -77,7 +79,7 @@ export function NavUser({
await logout()
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>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -4,7 +4,10 @@ import { TbDeviceHeartMonitor, TbEngine, TbEngineOff, TbServer2, TbServerBolt, T
import { useEffect } from 'react'
import { $platformInfo } from '@/store/user'
import { getPlatformInfo } from '@/api/platform'
export const PlatformInfo = () => {
import { useTranslation } from 'react-i18next';
export default function PlatformInfo() {
const { t } = useTranslation();
const platformInfo = useQuery({
queryKey: ['platformInfo'],
queryFn: getPlatformInfo,
@@ -17,73 +20,73 @@ export const PlatformInfo = () => {
<Card>
<CardHeader>
<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" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{platformInfo.data?.configuredServerCount} </div>
<p className="text-xs text-muted-foreground">🫲</p>
<div className="text-2xl font-bold">{platformInfo.data?.configuredServerCount} {t('platform.unit')}</div>
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<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" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{platformInfo.data?.configuredClientCount} </div>
<p className="text-xs text-muted-foreground">🫲</p>
<div className="text-2xl font-bold">{platformInfo.data?.configuredClientCount} {t('platform.unit')}</div>
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<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" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredServerCount} </div>
<p className="text-xs text-muted-foreground">🫲</p>
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredServerCount} {t('platform.unit')}</div>
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<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" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredClientCount} </div>
<p className="text-xs text-muted-foreground">🫲</p>
<div className="text-2xl font-bold">{platformInfo.data?.unconfiguredClientCount} {t('platform.unit')}</div>
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<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" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{platformInfo.data?.totalServerCount} </div>
<p className="text-xs text-muted-foreground">🫲</p>
<div className="text-2xl font-bold">{platformInfo.data?.totalServerCount} {t('platform.unit')}</div>
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<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" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{platformInfo.data?.totalClientCount} </div>
<p className="text-xs text-muted-foreground">🫲</p>
<div className="text-2xl font-bold">{platformInfo.data?.totalClientCount} {t('platform.unit')}</div>
<p className="text-xs text-muted-foreground">{t('platform.menuHint')}</p>
</CardContent>
</Card>
</div>

View File

@@ -2,7 +2,7 @@ import { ZodEmailSchema, ZodStringSchema } from '@/lib/consts'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
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 { register } from '@/api/auth'
import { Button } from './ui/button'
@@ -15,6 +15,7 @@ import { useToast } from './ui/use-toast'
import { RespCode } from '@/lib/pb/common'
import { useRouter } from 'next/router'
import { Toast } from './ui/toast'
import { useTranslation } from 'react-i18next';
export const RegisterSchema = z.object({
username: ZodStringSchema,
@@ -22,7 +23,8 @@ export const RegisterSchema = z.object({
email: ZodEmailSchema,
})
export const RegisterComponent = () => {
export function RegisterComponent() {
const { t } = useTranslation();
const form = useForm<z.infer<typeof RegisterSchema>>({
resolver: zodResolver(RegisterSchema),
})
@@ -35,20 +37,20 @@ export const RegisterComponent = () => {
}
const onSubmit = async (values: z.infer<typeof RegisterSchema>) => {
toast({ title: '注册中,请稍候' })
toast({ title: t('auth.registering') })
try {
const res = await register({ ...values })
if (res.status?.code === RespCode.SUCCESS) {
toast({ title: '注册成功,正在跳转到登录' })
toast({ title: t('auth.registerSuccess') })
setRegisterAlert(false)
await sleep(3000)
router.push('/login')
} else {
toast({ title: '注册失败' })
toast({ title: t('auth.registerFailed') })
setRegisterAlert(true)
}
} catch (e) {
toast({ title: '注册失败' })
toast({ title: t('auth.registerFailed') })
console.log('register error', e)
setRegisterAlert(true)
}
@@ -64,7 +66,7 @@ export const RegisterComponent = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="text" placeholder="用户名" {...field} />
<Input type="text" placeholder={t('auth.usernamePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -76,7 +78,7 @@ export const RegisterComponent = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="email" placeholder="邮箱地址" {...field} />
<Input type="email" placeholder={t('auth.emailPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -88,7 +90,7 @@ export const RegisterComponent = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="password" placeholder="密码" {...field} />
<Input type="password" placeholder={t('auth.passwordPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -97,11 +99,13 @@ export const RegisterComponent = () => {
{registerAlert && (
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
<AlertTitle>{t('auth.error')}</AlertTitle>
<AlertDescription>{t('auth.registerFailed')}</AlertDescription>
</Alert>
)}
<Button type="submit"></Button>
<Button type="submit">
{t('common.register')}
</Button>
</form>
</Form>
</div>

View File

@@ -11,11 +11,13 @@ import { ProxySelector } from '../base/proxy-selector'
import { ProxyInfo } from '@/lib/pb/common'
import { Button } from '../ui/button'
import { CheckCircle2, CircleX, RefreshCcw } from "lucide-react"
import { useTranslation } from 'react-i18next';
export interface ClientStatsCardProps {
clientID?: string
}
export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defaultClientID }: ClientStatsCardProps = {}) => {
const { t } = useTranslation();
const [clientID, setClientID] = useState<string | undefined>()
const [proxyName, setProxyName] = useState<string | undefined>()
const [status, setStatus] = useState<"loading" | "success" | "error" | undefined>()
@@ -75,20 +77,20 @@ export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defa
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t('client.stats.title')}</CardTitle>
<CardDescription>
<div>
使
{t('client.stats.description')}
</div>
</CardDescription>
</CardHeader>
<CardContent className='space-y-4 flex flex-col flex-1'>
<Label></Label>
<Label>{t('client.stats.label')}</Label>
<ClientSelector clientID={clientID} setClientID={handleClientChange} onOpenChange={() => {
refetchClientStats()
setProxyName(undefined)
}} />
<Label></Label>
<Label>{t('proxy.stats.label')}</Label>
<ProxySelector
// @ts-ignore
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} />
<div className="w-full grid gap-4 grid-cols-1">
{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>
</CardContent>
<CardFooter>
@@ -118,29 +121,35 @@ export const ClientStatsCard: React.FC<ClientStatsCardProps> = ({ clientID: defa
{status === "loading" && <RefreshCcw className="w-4 h-4 animate-spin" />}
{status === "success" && <CheckCircle2 className="w-4 h-4" />}
{status === "error" && <CircleX className="w-4 h-4" />}
<p></p></Button>
<p>{t('refresh.data')}</p></Button>
</CardFooter>
</Card>
)
}
const ProxyStatusCard = (proxyInfo: ProxyInfo | undefined) => {
return (<>{proxyInfo &&
const ProxyStatusCard: React.FC<{ proxyInfo: ProxyInfo | undefined }> = ({ proxyInfo }) => {
const { t } = useTranslation();
if (!proxyInfo) {
return null;
}
return (
<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} />
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
<ProxyTrafficPieChart
title='今日流量统计'
chartLabel='今日总流量'
title={t('proxy.stats.today_traffic_title')}
chartLabel={t('proxy.stats.today_traffic_total')}
trafficIn={proxyInfo.todayTrafficIn || BigInt(0)}
trafficOut={proxyInfo.todayTrafficOut || BigInt(0)} />
<ProxyTrafficPieChart
title='历史流量统计'
chartLabel='历史总流量'
title={t('proxy.stats.history_traffic_title')}
chartLabel={t('proxy.stats.history_traffic_total')}
trafficIn={proxyInfo.historyTrafficIn || BigInt(0)}
trafficOut={proxyInfo.historyTrafficOut || BigInt(0)} />
</div>
</div>
}</>)
);
}

View File

@@ -16,6 +16,7 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
import { CaretSortIcon, PlusIcon } from "@radix-ui/react-icons"
import { useTranslation } from "react-i18next"
export function TeamSwitcher({
teams,
@@ -29,6 +30,7 @@ export function TeamSwitcher({
const { isMobile } = useSidebar()
const [activeTeam, setActiveTeam] = React.useState(teams[0])
const router = useRouter()
const { t } = useTranslation()
return (
<SidebarMenu>
@@ -58,7 +60,7 @@ export function TeamSwitcher({
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t('team.title')}
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
@@ -85,7 +87,7 @@ export function TeamSwitcher({
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<PlusIcon className="size-4" />
</div>
<div className="font-medium text-muted-foreground">添加租户</div>
<div className="font-medium text-muted-foreground">{t('team.add')}</div>
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>

58
www/config/nav.ts Normal file
View 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
View 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
View 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
View 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": "最后页"
}
}
}
}

View File

@@ -1,6 +1,7 @@
import * as z from 'zod'
import { Client, Server } from './pb/common'
import { GetPlatformInfoResponse } from './pb/api_user'
import i18next from 'i18next';
export const API_PATH = '/api/v1'
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 ZodPortSchema = z.coerce
.number()
.min(1, {
message: '端口号不能小于 1',
})
.max(65535, { message: '端口号不能大于 65535' })
export const ZodIPSchema = z.string().regex(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, { message: '请输入正确的IP地址' })
export const ZodStringSchema = z.string().min(1, { message: '不能为空' })
export const ZodEmailSchema = z.string().min(1, { message: '不能为空' }).email('是不是输错了邮箱地址呢?')
.min(1, { message: i18next.t('validation.portRange.min') })
.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') })
export const ZodStringSchema = z.string().min(1, { message: i18next.t('validation.required') })
export const ZodEmailSchema = z.string().min(1, { message: i18next.t('validation.required') }).email({ message: i18next.t('auth.email.invalid') })
// .refine((e) => e === "abcd@fg.com", "This email is not in our database")
export const ExecCommandStr = <T extends Client | Server>(

View File

@@ -1,37 +1,45 @@
import i18n from 'i18next'
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import { atom } from 'nanostores'
import enTranslations from '../i18n/locales/en.json'
import zhTranslations from '../i18n/locales/zh.json'
const LANGUAGE_KEY = 'LANGUAGE'
const resources = {
// Get initial language from localStorage or default to 'zh'
const getInitialLanguage = () => {
if (typeof window === 'undefined') return 'zh'
return localStorage.getItem(LANGUAGE_KEY) || 'zh'
}
export const $language = atom(getInitialLanguage())
const i18n = i18next.createInstance()
i18n
.use(initReactI18next)
.init({
resources: {
en: {
translation: {
: 'New',
},
translation: enTranslations,
},
zh: {
translation: {
: '新建',
translation: zhTranslations,
},
},
} as const
export const $language = atom('zh')
lng: getInitialLanguage(),
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
})
export const setLanguage = async (lng: 'en' | 'zh') => {
await i18n.changeLanguage(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

View File

@@ -43,6 +43,8 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"framer-motion": "^11.13.1",
"i18next": "^23.7.18",
"lucide-react": "^0.462.0",
"nanostores": "^0.9.5",

View File

@@ -1,6 +1,12 @@
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { I18nextProvider } from 'react-i18next'
import i18n from '@/lib/i18n'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
return (
<I18nextProvider i18n={i18n}>
<Component {...pageProps} />
</I18nextProvider>
)
}

View File

@@ -1,3 +1,5 @@
"use client"
import { Providers } from '@/components/providers'
import { RootLayout } from '@/components/layout'
import { Header } from '@/components/header'
@@ -12,12 +14,17 @@ import { ServerSelector } from '@/components/base/server-selector'
import LoadingCircle from '@/components/base/status'
import { ClientStatus } from '@/lib/pb/api_master'
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'), {
ssr: false
})
export default function ConsolePage() {
const { t } = useTranslation();
const [clientID, setClientID] = useState<string | undefined>(undefined)
const [clear, setClear] = useState<number>(0)
const [enabled, setEnabled] = useState<boolean>(false)
@@ -63,45 +70,113 @@ export default function ConsolePage() {
};
}, [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 (
<Providers>
<RootLayout mainHeader={<Header />}>
<div className="w-full">
<div className="flex-1 flex-col space-y-2">
<div className="flex flex-1 flex-row gap-2 items-center">
<div className='items-center'>
<Card className="w-full h-[calc(100dvh_-_80px)] flex flex-col">
<CardContent className="p-3 flex-1 flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
<div className="flex items-center gap-1.5">
<LoadingCircle status={status} />
</div>
<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"
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}`)}>
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) => { if (value === ClientType.FRPC.toString()) { setClientType(ClientType.FRPC) } else { setClientType(ClientType.FRPS) } }}
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="客户端类型"
label={t('common.clientType')}
className="h-8"
/>
</div>
{clientType === ClientType.FRPC && <ClientSelector clientID={clientID} setClientID={setClientID} />}
{clientType === ClientType.FRPS && <ServerSelector serverID={clientID} setServerID={setClientID} />}
<div className='flex-1 h-[calc(100dvh_-_180px)]'>
</div>
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
{clientType === ClientType.FRPC && (
<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'
)}>
<TerminalComponent
setStatus={setStatus}
isLoading={!enabled}
@@ -113,7 +188,8 @@ export default function ConsolePage() {
reset={clear} />
</div>
</div>
</div>
</CardContent>
</Card>
</RootLayout>
</Providers>
)

View File

@@ -1,7 +1,7 @@
import { Providers } from '@/components/providers'
import { RootLayout } from '@/components/layout'
import { Header } from '@/components/header'
import { PlatformInfo } from '@/components/platforminfo'
import PlatformInfo from '@/components/platforminfo'
export default function Home() {
return (

View File

@@ -4,56 +4,90 @@ import { TbBuildingTunnel } from 'react-icons/tb'
import { LoginComponent } from '@/components/login'
import { useRouter } from 'next/router'
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'] })
export default function Login() {
export default function LoginPage() {
const router = useRouter()
const { t } = useTranslation();
return (
<main className={`${inter.className}`}>
<main className={`${inter.className} min-h-screen`}>
<Providers>
{/* Fixed Language Switcher */}
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
{/* Mobile Header */}
<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="absolute text-lg font-medium left-1/2 transform -translate-x-1/2 mt-3 lg:hidden"
className="text-lg font-medium flex items-center"
onClick={() => router.push('/')}
>
<div className="flex rounded hover:bg-slate-100 p-2">
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
FRP Panel
<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 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>
<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="relative flex items-center text-lg font-medium" onClick={() => router.push('/')}>
<div className="flex rounded hover:bg-zinc-800 p-2">
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
FRP Panel
<div className="relative z-20">
<div className="flex items-center text-lg font-medium" onClick={() => router.push('/')}>
<div className="flex items-center rounded hover:bg-zinc-800 p-2 text-white">
<TbBuildingTunnel className="mr-2 h-8 w-8" />
{t('app.title')}
</div>
</div>
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
A multi node frp webui and for <a href="https://github.com/fatedier/frp">[FRP]</a> server and client
management, which makes this project a [Cloudflare Tunnel] or [Tailscale Funnel] open source
alternative
<p className="text-lg leading-relaxed">
{t('app.description')}
</p>
<footer className="text-sm">
navigate to: <a href="https://github.com/VaalaCat/frp-panel">VaalaCat/frp-panel</a>
<footer className="text-sm mt-4 opacity-80">
{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>
</blockquote>
</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">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground"></p>
<h1 className="text-2xl font-semibold tracking-tight">
{t('auth.loginTitle')}
</h1>
<p className="text-sm text-muted-foreground">
{t('auth.inputCredentials')}
</p>
</div>
<div className="w-full justify-center">
<div className="w-[300px]">
<LoginComponent />
</div>
</div>
<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>

View File

@@ -4,56 +4,90 @@ import { TbBuildingTunnel } from 'react-icons/tb'
import { RegisterComponent } from '@/components/register'
import { useRouter } from 'next/router'
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'] })
export default function Login() {
export default function RegisterPage() {
const router = useRouter()
const { t } = useTranslation();
return (
<main className={`${inter.className}`}>
<main className={`${inter.className} min-h-screen`}>
<Providers>
{/* Fixed Language Switcher */}
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
{/* Mobile Header */}
<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="absolute text-lg font-medium left-1/2 transform -translate-x-1/2 mt-3 lg:hidden"
className="text-lg font-medium flex items-center"
onClick={() => router.push('/')}
>
<div className="flex rounded hover:bg-slate-100 p-2">
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
FRP Panel
<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 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>
<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="relative flex items-center text-lg font-medium" onClick={() => router.push('/')}>
<div className="flex rounded hover:bg-zinc-800 p-2">
<TbBuildingTunnel className="mr-2 h-8 w-8 pb-1" />
FRP Panel
<div className="relative z-20">
<div className="flex items-center text-lg font-medium" onClick={() => router.push('/')}>
<div className="flex items-center rounded hover:bg-zinc-800 p-2 text-white">
<TbBuildingTunnel className="mr-2 h-8 w-8" />
{t('app.title')}
</div>
</div>
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
A multi node frp webui and for <a href="https://github.com/fatedier/frp">[FRP]</a> server and client
management, which makes this project a [Cloudflare Tunnel] or [Tailscale Funnel] open source
alternative
<p className="text-lg leading-relaxed">
{t('app.description')}
</p>
<footer className="text-sm">
navigate to: <a href="https://github.com/VaalaCat/frp-panel">VaalaCat/frp-panel</a>
<footer className="text-sm mt-4 opacity-80">
{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>
</blockquote>
</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">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground"></p>
<h1 className="text-2xl font-semibold tracking-tight">
{t('auth.registerTitle')}
</h1>
<p className="text-sm text-muted-foreground">
{t('auth.inputCredentials')}
</p>
</div>
<div className="w-full justify-center">
<div className="w-[300px]">
<RegisterComponent />
</div>
</div>
<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>

View File

@@ -1,3 +1,5 @@
"use client"
import { Providers } from '@/components/providers'
import { RootLayout } from '@/components/layout'
import { Header } from '@/components/header'
@@ -12,12 +14,18 @@ import { BaseSelector } from '@/components/base/selector'
import { ServerSelector } from '@/components/base/server-selector'
import LoadingCircle from '@/components/base/status'
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'), {
ssr: false
})
export default function StreamLogPage() {
const { t } = useTranslation();
const [clientID, setClientID] = useState<string | undefined>(undefined)
const [log, setLog] = useState<string | undefined>(undefined)
const [clear, setClear] = useState<number>(0)
@@ -49,18 +57,16 @@ export default function StreamLogPage() {
useEffect(() => {
setClear(Math.random())
setStatus(undefined)
if (!clientID) {
return;
}
if (!enabled) {
if (!clientID || !enabled) {
return;
}
const abortController = new AbortController();
setStatus("loading");
void parseStreaming(
abortController,
clientID!,
clientID,
setLog,
(status: number) => {
if (status === 200) {
@@ -74,50 +80,113 @@ export default function StreamLogPage() {
setStatus("success")
}
);
return () => {
abortController.abort("unmount");
setEnabled(false);
};
}, [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 (
<Providers>
<RootLayout mainHeader={<Header />}>
<div className="w-full">
<div className="flex-1 flex-col space-y-2">
<div className="flex flex-1 flex-row gap-2 items-center">
<div className='items-center'>
<Card className="w-full h-[calc(100dvh_-_80px)] flex flex-col">
<CardContent className="p-3 flex-1 flex flex-col gap-2 first-letter:">
<div className="flex flex-wrap items-center gap-1.5 shrink-0">
<div className="flex items-center gap-1.5">
<LoadingCircle status={status} />
</div>
<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"
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>
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) => { if (value === ClientType.FRPC.toString()) { setClientType(ClientType.FRPC) } else { setClientType(ClientType.FRPS) } }}
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="客户端类型"
label={t('common.clientType')}
className="h-8"
/>
</div>
{clientType === ClientType.FRPC && <ClientSelector clientID={clientID} setClientID={setClientID} />}
{clientType === ClientType.FRPS && <ServerSelector serverID={clientID} setServerID={setClientID} />}
<div className='flex-1 h-[calc(100dvh_-_180px)]'>
</div>
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
{clientType === ClientType.FRPC && (
<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>
</CardContent>
</Card>
</RootLayout>
</Providers>
)

78
www/pnpm-lock.yaml generated
View File

@@ -110,6 +110,12 @@ importers:
cmdk:
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)
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:
specifier: ^23.7.18
version: 23.7.18
@@ -313,59 +319,59 @@ packages:
resolution: {integrity: sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ==}
'@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'}
cpu: [arm64]
os: [darwin]
'@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'}
cpu: [x64]
os: [darwin]
'@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'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@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'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@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'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@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'}
cpu: [x64]
os: [linux]
libc: [musl]
'@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'}
cpu: [arm64]
os: [win32]
'@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'}
cpu: [ia32]
os: [win32]
'@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'}
cpu: [x64]
os: [win32]
@@ -383,7 +389,7 @@ packages:
engines: {node: '>= 8'}
'@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'}
'@protobuf-ts/plugin-framework@2.9.3':
@@ -1095,7 +1101,7 @@ packages:
'@xterm/xterm': ^5.0.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:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@@ -1360,6 +1366,9 @@ packages:
damerau-levenshtein@1.0.8:
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:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -1430,7 +1439,7 @@ packages:
resolution: {integrity: sha512-lKoz10iCYlP1WtRYdh5MvocQPWVRoI7ysp6qf18bmeBgR8abE6+I2CsfyNKztRDZvhdWc+krKT6wS7Neg8sw3A==}
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:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1631,11 +1640,25 @@ packages:
fraction.js@4.3.7:
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:
resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
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}
os: [darwin]
@@ -1807,7 +1830,7 @@ packages:
resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==}
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'}
is-generator-function@1.0.10:
@@ -2000,6 +2023,12 @@ packages:
resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==}
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:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
@@ -2432,7 +2461,7 @@ packages:
engines: {node: '>=0.10.0'}
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'}
string-width@5.1.2:
@@ -2541,7 +2570,7 @@ packages:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
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:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
@@ -2661,7 +2690,7 @@ packages:
hasBin: true
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'}
wrap-ansi@8.1.0:
@@ -3870,6 +3899,8 @@ snapshots:
damerau-levenshtein@1.0.8: {}
date-fns@4.1.0: {}
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -4268,6 +4299,15 @@ snapshots:
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: {}
fsevents@2.3.3:
@@ -4637,6 +4677,10 @@ snapshots:
minipass@7.0.4: {}
motion-dom@11.13.0: {}
motion-utils@11.13.0: {}
ms@2.1.2: {}
ms@2.1.3: {}

View File

@@ -8,3 +8,13 @@ export const $userInfo = atom<User | undefined>()
export const $statusOnline = atom<boolean>(false)
export const $token = persistentAtom<string | undefined>(LOCAL_STORAGE_TOKEN_KEY)
export const $platformInfo = atom<GetPlatformInfoResponse | undefined>()
// 创建持久化的语言设置
export const $language = persistentAtom<string>(
'user-language',
'zh',
{
encode: JSON.stringify,
decode: JSON.parse
}
)

View File

@@ -5,7 +5,7 @@ module.exports = {
prefix: '',
theme: {
container: {
center: 'true',
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
@@ -64,25 +64,35 @@ module.exports = {
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' }
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 }
},
to: {
height: '0'
ping: {
'75%, 100%': {
transform: 'scale(1.5)',
opacity: '0'
}
},
breathe: {
'0%, 100%': {
transform: 'scale(0.95)',
opacity: '0.5'
},
'50%': {
transform: 'scale(1)',
opacity: '1'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 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',
}
}
},