Подготовить HY2XS к production-сборке

This commit is contained in:
2026-04-25 23:13:12 +05:00
commit 84a4e94567
277 changed files with 26513 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
dist/
builder/output/
tools/build/output/
.toolchain/
orchestrator/dist/
orchestrator/node_modules/
apps/frontend/node_modules/
apps/frontend/dist/*
!apps/frontend/dist/.embed-placeholder
apps/data/
apps/logs/
apps/bin/
apps/export/
+76
View File
@@ -0,0 +1,76 @@
# HY2XS
HY2XS is a two-layer install package for a clean Debian 12 target:
- local builder layer: creates one transferable install archive
- install-only Bun/TypeScript orchestrator: runs on the target as a compiled artifact
- bundled HY2XS admin fork: shipped inside the package
- vanilla Hysteria2: downloaded from the official upstream during install
The baseline deliberately excludes target-side builds, update, rollback, uninstall, Telegram bot delivery, billing, and external access/profile delivery.
## Repository Layout
- `builder/` - local shell-first packaging pipeline
- `orchestrator/` - Bun + TypeScript install-only orchestrator sources
- `package/` - package skeleton: thin entrypoint, templates, systemd units, package docs
- `apps/` - current HY2XS admin fork source
- `docs/` - baseline architecture and acceptance docs
- `hy2xs_implementation_plan-no_git/` - implementation roadmap and task breakdown
- `dist/` - final install archives generated by the builder
## Build
On the local build machine:
```sh
PACKAGE_VERSION=0.1.0 ./builder/build.sh
```
If tools are not in `PATH`, pass explicit binaries:
```sh
GO_BIN=/path/to/go PNPM_BIN=/path/to/pnpm BUN_BIN=/path/to/bun PACKAGE_VERSION=0.1.0 ./builder/build.sh
```
On the current Windows workstation the known tool paths are:
```powershell
$env:PATH = "E:\Git\cmd;E:\go\bin;" + $env:PATH
E:\go\bin\go.exe test ./...
pnpm.cmd install --frozen-lockfile
```
The expected result is:
```text
dist/hy2xs-install-0.1.0.tar.gz
```
The target server must not run `bun install`, `pnpm install`, TypeScript transpilation, frontend builds, or Go builds.
## Publication
Target repository:
```text
https://git.ext.flamy.studio/flamy_dev/HY2XS_flamy.git
```
Publish only after the full baseline acceptance is complete, including the Bun/TypeScript orchestrator artifact build and final package smoke checks.
## Install
On a clean Debian 12 target, unpack the archive and run as root:
```sh
./install.sh --non-interactive --domain example.com
```
Useful baseline flags:
```sh
./install.sh --non-interactive --domain example.com --port 443 --ssh-port 22 --ui-port 8080
```
The install flow writes `/etc/hysteria/post-install.env` with the resulting package, Hysteria, port, and path state.
+5
View File
@@ -0,0 +1,5 @@
*.ts linguist-language=Go
*.js linguist-language=Go
*.css linguist-language=Go
*.scss linguist-language=Go
*.html linguist-language=Go
+6
View File
@@ -0,0 +1,6 @@
/.idea
/bin
/build
/data
/export
/logs
+50
View File
@@ -0,0 +1,50 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"hy2xs-admin/model/constant"
"hy2xs-admin/util"
"os"
)
var rootCmd = &cobra.Command{
Use: "hy2xs-admin",
Short: "HY2XS admin panel for Hysteria2",
Long: "HY2XS admin panel for Hysteria2.",
Run: run,
}
var (
version bool
port string
)
func init() {
rootCmd.Flags().BoolVarP(&version, "version", "v", false, "Show version")
rootCmd.Flags().StringVarP(&port, "port", "p", "", "The port of the web server")
}
func run(cmd *cobra.Command, args []string) {
if version {
fmt.Println("HY2XS admin version", constant.Version)
return
}
if err := util.VerifyPort(port); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
for {
if err := runServer(port); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
}
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
+51
View File
@@ -0,0 +1,51 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"hy2xs-admin/dao"
"hy2xs-admin/util"
"os"
)
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Reset username and password",
Long: "Reset username and password.",
Run: runReset,
}
func init() {
rootCmd.AddCommand(resetCmd)
}
func runReset(cmd *cobra.Command, args []string) {
username, err := util.RandomString(6)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
password, err := util.RandomString(6)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.InitSqliteDB(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.UpdateAccount([]int64{1}, map[string]interface{}{
"username": username,
"pass": util.SHA224String(password),
"con_pass": fmt.Sprintf("%s.%s", username, password)}); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.CloseSqliteDB(); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
fmt.Println(fmt.Sprintf("HY2XS admin Connection Password: %s", fmt.Sprintf("%s.%s", username, password)))
}
+85
View File
@@ -0,0 +1,85 @@
package cmd
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao"
"hy2xs-admin/middleware"
"hy2xs-admin/model/constant"
"hy2xs-admin/router"
"hy2xs-admin/service"
"hy2xs-admin/util"
"net/http"
"os"
)
func runServer(port string) error {
defer releaseResource()
middleware.InitLog()
service.InitForward()
if err := initFile(); err != nil {
return err
}
if err := dao.InitSql(port); err != nil {
return err
}
if err := middleware.InitCron(); err != nil {
return err
}
if err := service.InitHysteria2(); err != nil {
return err
}
if err := service.InitTableAndChain(); err != nil {
logrus.Errorf(err.Error())
}
if err := service.InitPortHopping(); err != nil {
logrus.Errorf(err.Error())
}
config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
if err != nil {
return err
}
r := gin.Default()
router.Router(r, config.Value)
serverPort, crtPath, keyPath, err := service.GetServerPortAndCert()
if err != nil {
return err
}
service.InitServer(fmt.Sprintf(":%d", serverPort), r)
if err := service.StartServer(crtPath, keyPath); err != nil && err != http.ErrServerClosed {
logrus.Errorf("start server err: %v", err)
return errors.New("start server err")
}
return nil
}
func releaseResource() {
if err := dao.CloseSqliteDB(); err != nil {
logrus.Errorf(err.Error())
}
if err := service.ReleaseHysteria2(); err != nil {
logrus.Errorf(err.Error())
}
if err := service.RemoveByComment(); err != nil {
logrus.Errorf(err.Error())
}
}
func initFile() error {
var dirs = []string{constant.LogDir, constant.SqliteDBDir, constant.BinDir, constant.ExportPathDir}
for _, item := range dirs {
if !util.Exists(item) {
if err := os.Mkdir(item, os.ModePerm); err != nil {
logrus.Errorf("%s create err: %v", item, err)
return errors.New(fmt.Sprintf("%s create err", item))
}
}
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"hy2xs-admin/model/constant"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show version",
Long: "Show version.",
Run: runVersion,
}
func init() {
rootCmd.AddCommand(versionCmd)
}
func runVersion(cmd *cobra.Command, args []string) {
fmt.Println("HY2XS admin version", constant.Version)
}
+335
View File
@@ -0,0 +1,335 @@
package controller
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"hy2xs-admin/util"
"io"
"strings"
"time"
)
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
if !service.ExistAccountUsername(*loginDto.Username, 0) {
vo.Fail("account not exist", c)
return
}
token, err := service.Login(*loginDto.Username, util.SHA224String(*loginDto.Pass))
if err != nil {
vo.Fail(err.Error(), c)
return
}
jwtVo := vo.JwtVo{
TokenType: constant.TokenType,
AccessToken: token,
}
vo.Success(jwtVo, c)
}
func PageAccount(c *gin.Context) {
accountPageDto, err := validateField(c, dto.AccountPageDto{})
if err != nil {
return
}
accounts, total, err := service.PageAccount(accountPageDto)
if err != nil {
vo.Fail(err.Error(), c)
return
}
onlineUsers, err := service.Hysteria2Online()
if err != nil {
vo.Fail(err.Error(), c)
return
}
var accountVos []vo.AccountVo
for _, item := range accounts {
accountVo := vo.AccountVo{
Username: *item.Username,
Quota: *item.Quota,
Download: *item.Download,
Upload: *item.Upload,
ExpireTime: *item.ExpireTime,
KickUtilTime: *item.KickUtilTime,
DeviceNo: *item.DeviceNo,
Role: *item.Role,
Deleted: *item.Deleted,
BaseVo: vo.BaseVo{
Id: *item.Id,
CreateTime: *item.CreateTime,
},
LoginAt: *item.LoginAt,
ConAt: *item.ConAt,
Remark: *item.Remark,
}
if value, exists := onlineUsers[*item.Username]; exists {
accountVo.Online = true
accountVo.Device = value
delete(onlineUsers, *item.Username)
}
accountVos = append(accountVos, accountVo)
}
accountPageVo := vo.AccountPageVo{
AccountVos: accountVos,
Total: total,
}
vo.Success(accountPageVo, c)
}
func SaveAccount(c *gin.Context) {
accountSaveDto, err := validateField(c, dto.AccountSaveDto{})
if err != nil {
return
}
if service.ExistAccountUsername(*accountSaveDto.Username, 0) {
vo.Fail(fmt.Sprintf("username %s already exists", *accountSaveDto.Username), c)
return
}
passEncrypt := util.SHA224String(*accountSaveDto.Pass)
conPass := fmt.Sprintf("%s.%s", *accountSaveDto.Username, *accountSaveDto.ConPass)
account := entity.Account{
Username: accountSaveDto.Username,
Pass: &passEncrypt,
ConPass: &conPass,
Quota: accountSaveDto.Quota,
ExpireTime: accountSaveDto.ExpireTime,
DeviceNo: accountSaveDto.DeviceNo,
Deleted: accountSaveDto.Deleted,
Remark: accountSaveDto.Remark,
}
err = service.SaveAccount(account)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func DeleteAccount(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return
}
account, err := service.GetAccount(*idDto.Id)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *account.Role == "admin" {
vo.Fail("admin cannot be deleted", c)
return
}
err = service.DeleteAccount([]int64{*idDto.Id})
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func UpdateAccount(c *gin.Context) {
accountUpdateDto, err := validateField(c, dto.AccountUpdateDto{})
if err != nil {
return
}
if accountUpdateDto.Username != nil && *accountUpdateDto.Username != "" && service.ExistAccountUsername(*accountUpdateDto.Username, *accountUpdateDto.Id) {
vo.Fail(fmt.Sprintf("username %s already exists", *accountUpdateDto.Username), c)
return
}
if accountUpdateDto.Deleted != nil && *accountUpdateDto.Deleted == 1 {
account, err := service.GetAccount(*accountUpdateDto.Id)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *account.Role == "admin" {
vo.Fail("the admin account cannot be deleted", c)
return
}
}
var passEncrypt *string
if accountUpdateDto.Pass != nil && *accountUpdateDto.Pass != "" {
passEncryptSha224 := util.SHA224String(*accountUpdateDto.Pass)
passEncrypt = &passEncryptSha224
}
account := entity.Account{
Username: accountUpdateDto.Username,
Pass: passEncrypt,
ConPass: accountUpdateDto.ConPass,
Quota: accountUpdateDto.Quota,
ExpireTime: accountUpdateDto.ExpireTime,
DeviceNo: accountUpdateDto.DeviceNo,
Deleted: accountUpdateDto.Deleted,
Remark: accountUpdateDto.Remark,
BaseEntity: entity.BaseEntity{
Id: accountUpdateDto.Id,
},
}
if err = service.UpdateAccount(account); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func ResetTraffic(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return
}
if err = service.ResetTraffic(*idDto.Id); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func GetAccountInfo(c *gin.Context) {
accountInfoVo, err := service.GetAccountInfo(c)
if err != nil {
vo.Fail(err.Error(), c)
return
}
// 更新最近登录时间
now := time.Now().UnixMilli()
if err = service.UpdateAccount(entity.Account{
BaseEntity: entity.BaseEntity{Id: &accountInfoVo.Id},
LoginAt: &now,
}); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(accountInfoVo, c)
}
func GetAccount(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return
}
account, err := service.GetAccount(*idDto.Id)
if err != nil {
vo.Fail(err.Error(), c)
return
}
accountVo := vo.AccountVo{
BaseVo: vo.BaseVo{
Id: *account.Id,
CreateTime: *account.CreateTime,
},
Username: *account.Username,
Quota: *account.Quota,
Download: *account.Download,
Upload: *account.Upload,
ExpireTime: *account.ExpireTime,
DeviceNo: *account.DeviceNo,
Role: *account.Role,
Deleted: *account.Deleted,
Remark: *account.Remark,
}
vo.Success(accountVo, c)
}
func ImportAccount(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
return
}
// 文件大小 2MB
if header.Size > 1024*1024*2 {
vo.Fail("the file is too big", c)
return
}
// 文件后缀.json
if !strings.HasSuffix(header.Filename, ".json") {
vo.Fail("file format not supported", c)
return
}
content, err := io.ReadAll(file)
if err != nil {
vo.Fail("json file read err", c)
return
}
var accounts []entity.Account
if err = json.Unmarshal(content, &accounts); err != nil {
vo.Fail("content Unmarshal err", c)
return
}
if err = service.UpsertAccount(accounts); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func ExportAccount(c *gin.Context) {
accountExports, err := service.ListExportAccount()
if err != nil {
vo.Fail(err.Error(), c)
return
}
fileName := fmt.Sprintf("AccountExport-%s.json", time.Now().Format("20060102150405"))
filePath := constant.ExportPathDir + fileName
if err = util.ExportFile(filePath, accountExports, 0); err != nil {
vo.Fail(err.Error(), c)
return
}
// 下载
if !util.Exists(filePath) {
vo.Fail("file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
}
func ReleaseKickAccount(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return
}
if err = service.ReleaseKickAccount(*idDto.Id); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func VerifyDefaultPass(c *gin.Context) {
info, err := service.GetAccountInfo(c)
if err != nil {
vo.Fail(err.Error(), c)
return
}
account, err := service.GetAccount(info.Id)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(account.Pass != nil && *account.Pass == "02f382b76ca1ab7aa06ab03345c7712fd5b971fb0c0f2aef98bac9cd", c)
}
+503
View File
@@ -0,0 +1,503 @@
package controller
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"hy2xs-admin/util"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
func UpdateConfigs(c *gin.Context) {
configsUpdateDto, err := validateField(c, dto.ConfigsUpdateDto{})
if err != nil {
return
}
port, crtPath, keyPath, err := service.GetPortAndCert()
if err != nil {
vo.Fail(err.Error(), c)
return
}
needResetPortHopping := false
needRestart := false
for _, item := range configsUpdateDto.ConfigUpdateDtos {
key := *item.Key
value := *item.Value
if key == constant.HUIWebPort && strconv.FormatInt(port, 10) != value {
port, err := strconv.Atoi(value)
if err != nil {
vo.Fail(fmt.Sprintf("port: %s is invalid", value), c)
return
}
if !util.IsPortAvailable(uint(port), "tcp") {
vo.Fail(fmt.Sprintf("port: %s is used", value), c)
return
}
needRestart = true
}
if key == constant.HUICrtPath && crtPath != value {
if value != "" && !util.Exists(value) {
vo.Fail(fmt.Sprintf("crt path: %s is not exist", value), c)
return
}
needRestart = true
}
if key == constant.HUIKeyPath && keyPath != value {
if value != "" && !util.Exists(value) {
vo.Fail(fmt.Sprintf("key path: %s is not exist", value), c)
return
}
needRestart = true
}
if key == constant.HUIWebContext {
huiWebContext, err := service.GetConfig(constant.HUIWebContext)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *huiWebContext.Value != value {
needRestart = true
}
}
if key == constant.Hysteria2ConfigPortHopping {
re := regexp.MustCompile(`^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$`)
if value != "" && !re.MatchString(value) {
vo.Fail(fmt.Sprintf("port hopping: %s is invalid", value), c)
return
}
hysteria2ConfigPortHopping, err := service.GetConfig(constant.Hysteria2ConfigPortHopping)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *hysteria2ConfigPortHopping.Value != value {
needResetPortHopping = true
}
}
if key == constant.ResetTrafficCron {
resetTrafficCron, err := service.GetConfig(constant.ResetTrafficCron)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if *resetTrafficCron.Value != value {
needRestart = true
}
}
if err = service.UpdateConfig(key, value); err != nil {
vo.Fail(err.Error(), c)
return
}
}
if needResetPortHopping {
if err := service.InitPortHopping(); err != nil {
vo.Fail(err.Error(), c)
return
}
}
if needRestart {
go func() {
_ = service.StopServer()
}()
}
vo.Success(nil, c)
}
func GetConfig(c *gin.Context) {
configDto, err := validateField(c, dto.ConfigDto{})
if err != nil {
return
}
config, err := service.GetConfig(*configDto.Key)
if err != nil {
vo.Fail(err.Error(), c)
return
}
configVo := vo.ConfigVo{
Key: *config.Key,
Value: *config.Value,
}
running := service.Hysteria2IsRunning()
if (*config.Value == "1") != running {
enable := "0"
if running {
enable = "1"
}
if err := service.UpdateConfig(constant.Hysteria2Enable, enable); err != nil {
vo.Fail(err.Error(), c)
return
}
configVo.Value = enable
}
vo.Success(configVo, c)
}
func ListConfig(c *gin.Context) {
configsDto, err := validateField(c, dto.ConfigsDto{})
if err != nil {
return
}
configs, err := service.ListConfig(configsDto.Keys)
if err != nil {
vo.Fail(err.Error(), c)
return
}
var configVos []vo.ConfigVo
for _, item := range configs {
configVo := vo.ConfigVo{
Key: *item.Key,
Value: *item.Value,
}
configVos = append(configVos, configVo)
}
vo.Success(configVos, c)
}
func GetHysteria2Config(c *gin.Context) {
config, err := service.GetHysteria2Config()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(config, c)
}
func UpdateHysteria2Config(c *gin.Context) {
hysteria2ServerConfig, err := validateField(c, bo.Hysteria2ServerConfig{})
if err != nil {
return
}
hysteria2Config, err := service.GetHysteria2Config()
if err != nil {
vo.Fail(err.Error(), c)
return
}
needResetPortHopping := false
if hysteria2Config.Listen != nil &&
*hysteria2Config.Listen != "" &&
hysteria2ServerConfig.Listen != nil &&
*hysteria2ServerConfig.Listen != "" &&
*hysteria2ServerConfig.Listen != *hysteria2Config.Listen {
needResetPortHopping = true
}
if err = service.UpdateHysteria2Config(hysteria2ServerConfig); err != nil {
vo.Fail(err.Error(), c)
return
}
if needResetPortHopping {
if err := service.InitPortHopping(); err != nil {
vo.Fail(err.Error(), c)
return
}
}
running := service.Hysteria2IsRunning()
if running {
if err = service.RestartHysteria2(); err != nil {
vo.Fail(err.Error(), c)
return
}
}
vo.Success(nil, c)
}
func ExportHysteria2Config(c *gin.Context) {
hysteria2ServerConfig, err := service.GetHysteria2Config()
if err != nil {
vo.Fail(err.Error(), c)
return
}
// 默认值
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret})
if err != nil {
vo.Fail(err.Error(), c)
return
}
var hUIWebPort string
var jwtSecret string
for _, item := range config {
if *item.Key == constant.HUIWebPort {
hUIWebPort = *item.Value
} else if *item.Key == constant.JwtSecret {
jwtSecret = *item.Value
}
}
if hUIWebPort == "" || jwtSecret == "" {
logrus.Errorf("hUIWebPort or jwtSecret is nil")
vo.Fail(constant.SysError, c)
return
}
authHttpUrl, err := service.GetAuthHttpUrl()
if err != nil {
vo.Fail(err.Error(), c)
return
}
authType := "http"
authHttpInsecure := true
var auth bo.ServerConfigAuth
auth.Type = &authType
var http bo.ServerConfigAuthHTTP
http.URL = &authHttpUrl
http.Insecure = &authHttpInsecure
auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth
hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
filePath := constant.ExportPathDir + fileName
if err = util.ExportFile(filePath, hysteria2ServerConfig, 1); err != nil {
vo.Fail(err.Error(), c)
return
}
if !util.Exists(filePath) {
vo.Fail("file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
}
func ImportHysteria2Config(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
return
}
if header.Size > 1024*1024*2 {
vo.Fail("the file is too big", c)
return
}
if !strings.HasSuffix(header.Filename, ".yaml") {
vo.Fail("file format not supported", c)
return
}
content, err := io.ReadAll(file)
if err != nil {
vo.Fail("yaml file read err", c)
return
}
var hysteria2ServerConfig bo.Hysteria2ServerConfig
if err = yaml.Unmarshal(content, &hysteria2ServerConfig); err != nil {
vo.Fail("content Unmarshal err", c)
return
}
// 默认值
config, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.JwtSecret})
if err != nil {
vo.Fail(err.Error(), c)
return
}
var hUIWebPort string
var jwtSecret string
for _, item := range config {
if *item.Key == constant.HUIWebPort {
hUIWebPort = *item.Value
} else if *item.Key == constant.JwtSecret {
jwtSecret = *item.Value
}
}
if hUIWebPort == "" || jwtSecret == "" {
logrus.Errorf("hUIWebPort or jwtSecret is nil")
vo.Fail(constant.SysError, c)
return
}
authHttpUrl, err := service.GetAuthHttpUrl()
if err != nil {
vo.Fail(err.Error(), c)
return
}
authType := "http"
authHttpInsecure := true
var auth bo.ServerConfigAuth
auth.Type = &authType
var http bo.ServerConfigAuthHTTP
http.URL = &authHttpUrl
http.Insecure = &authHttpInsecure
auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth
hysteria2ServerConfig.TrafficStats.Secret = &jwtSecret
if err = service.SetHysteria2Config(hysteria2ServerConfig); err != nil {
vo.Fail(err.Error(), c)
return
}
running := service.Hysteria2IsRunning()
if running {
if err = service.RestartHysteria2(); err != nil {
vo.Fail(err.Error(), c)
return
}
}
vo.Success(nil, c)
}
func ExportConfig(c *gin.Context) {
configs, err := service.ListConfigNotIn([]string{constant.Hysteria2Config})
if err != nil {
vo.Fail(err.Error(), c)
return
}
fileName := fmt.Sprintf("SystemConfig-%s.json", time.Now().Format("20060102150405"))
filePath := constant.ExportPathDir + fileName
if err = util.ExportFile(filePath, configs, 0); err != nil {
vo.Fail(err.Error(), c)
return
}
if !util.Exists(filePath) {
vo.Fail("file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
}
func ImportConfig(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
return
}
if header.Size > 1024*1024*2 {
vo.Fail("the file is too big", c)
return
}
if !strings.HasSuffix(header.Filename, ".json") {
vo.Fail("file format not supported", c)
return
}
content, err := io.ReadAll(file)
if err != nil {
vo.Fail("json file read err", c)
return
}
var configs []entity.Config
if err = json.Unmarshal(content, &configs); err != nil {
vo.Fail("content Unmarshal err", c)
return
}
if err = service.UpsertConfig(configs); err != nil {
vo.Fail(err.Error(), c)
return
}
go func() {
_ = service.StopServer()
}()
vo.Success(nil, c)
}
func Hysteria2AcmePath(c *gin.Context) {
hysteria2AcmePathVo, err := service.Hysteria2AcmePath()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(hysteria2AcmePathVo, c)
}
func RestartServer(c *gin.Context) {
go func() {
_ = service.StopServer()
}()
vo.Success(nil, c)
}
func UploadCertFile(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
return
}
ext := filepath.Ext(file.Filename)
if ext != ".crt" && ext != ".key" {
vo.Fail("file format not supported", c)
return
}
if file.Size > 1024*1024 {
vo.Fail("the file is too big", c)
return
}
err = filepath.WalkDir(constant.BinDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
fileExt := filepath.Ext(path)
if !d.IsDir() && fileExt == ext {
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to delete file: %s, error: %v", path, err)
}
}
return nil
})
if err != nil {
logrus.Errorf("error during file deletion: %v", err)
vo.Fail("delete file failed", c)
return
}
wd, err := os.Getwd()
if err != nil {
vo.Fail(constant.SysError, c)
return
}
safeFilename := filepath.Base(file.Filename)
certPath := filepath.Join(wd, constant.BinDir, safeFilename)
if err := c.SaveUploadedFile(file, certPath); err != nil {
vo.Fail("file upload failed", c)
return
}
vo.Success(certPath, c)
}
+152
View File
@@ -0,0 +1,152 @@
package controller
import (
"encoding/base64"
"github.com/gin-gonic/gin"
"github.com/skip2/go-qrcode"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"net/url"
"strings"
"time"
)
func Hysteria2Auth(c *gin.Context) {
hysteria2AuthDto, err := validateField(c, dto.Hysteria2AuthDto{})
if err != nil {
return
}
id, username, err := service.Hysteria2Auth(*hysteria2AuthDto.Auth)
if err != nil || username == "" {
vo.Hysteria2AuthFail("", c)
return
}
// 更新最近连接时间
now := time.Now().UnixMilli()
if err = service.UpdateAccount(entity.Account{
BaseEntity: entity.BaseEntity{Id: &id},
ConAt: &now,
}); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Hysteria2AuthSuccess(username, c)
}
func Hysteria2Kick(c *gin.Context) {
hysteria2KickDto, err := validateField(c, dto.Hysteria2KickDto{})
if err != nil {
return
}
err = service.Hysteria2Kick(hysteria2KickDto.Ids, *hysteria2KickDto.KickUtilTime)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func Hysteria2ChangeVersion(c *gin.Context) {
vo.Fail("Смена версии Hysteria2 отключена: runtime управляется install-оркестратором HY2XS", c)
}
func ListRelease(c *gin.Context) {
vo.Success([]string{}, c)
}
func Hysteria2Url(c *gin.Context) {
hysteria2UrlDto, err := validateField(c, dto.Hysteria2UrlDto{})
if err != nil {
return
}
url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId, *hysteria2UrlDto.Hostname)
if err != nil {
vo.Fail(err.Error(), c)
return
}
// 生成二维码
qrCode, err := qrcode.Encode(url, qrcode.Medium, 300)
if err != nil {
vo.Fail(err.Error(), c)
return
}
hysteria2UrlVo := vo.Hysteria2UrlVo{
Url: url,
QrCode: qrCode,
}
vo.Success(hysteria2UrlVo, c)
}
func Hysteria2SubscribeUrl(c *gin.Context) {
hysteria2SubscribeUrlDto, err := validateField(c, dto.Hysteria2SubscribeUrlDto{})
if err != nil {
return
}
subscribeUrl, err := service.Hysteria2SubscribeUrl(*hysteria2SubscribeUrlDto.AccountId,
*hysteria2SubscribeUrlDto.Protocol,
*hysteria2SubscribeUrlDto.Host)
if err != nil {
vo.Fail(err.Error(), c)
return
}
qrCode, err := qrcode.Encode(subscribeUrl, qrcode.Medium, 300)
if err != nil {
vo.Fail(err.Error(), c)
return
}
subscribeVo := vo.Hysteria2SubscribeVo{
Url: subscribeUrl,
QrCode: qrCode,
}
vo.Success(subscribeVo, c)
}
func Hysteria2Subscribe(c *gin.Context) {
conPass := c.Param("conPass")
conPass, err := url.QueryUnescape(conPass)
if err != nil {
vo.Fail("url decode err", c)
return
}
userAgent := strings.ToLower(c.Request.Header.Get("User-Agent"))
host := c.Request.Host
if host == "" {
vo.Fail("Host is empty", c)
return
}
var clientType string
if strings.Contains(userAgent, constant.Shadowrocket) {
clientType = constant.Shadowrocket
} else if strings.Contains(userAgent, constant.Clash) {
clientType = constant.Clash
} else if strings.Contains(userAgent, constant.V2rayN) {
clientType = constant.V2rayN
} else if strings.Contains(userAgent, constant.NekoBox) {
clientType = constant.NekoBox
} else {
clientType = constant.Clash
}
userInfo, configStr, err := service.Hysteria2Subscribe(conPass, clientType, host)
if err != nil {
vo.Fail(err.Error(), c)
return
}
if clientType == constant.Shadowrocket || clientType == constant.Clash {
c.Header("content-disposition", "attachment; filename=hui.yaml")
c.Header("profile-update-interval", "12")
c.Header("subscription-userinfo", userInfo)
} else if clientType == constant.V2rayN {
configStr = base64.StdEncoding.EncodeToString([]byte(configStr))
}
c.String(200, configStr)
}
+116
View File
@@ -0,0 +1,116 @@
package controller
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"time"
)
func LogSystem(c *gin.Context) {
logSystemDto, err := validateField(c, dto.LogDto{})
if err != nil {
return
}
exists := util.Exists(constant.SystemLogPath)
logSystemVos := make([]vo.LogSystemVo, 0)
if !exists {
vo.Success(logSystemVos, c)
return
}
numLine := 0
if logSystemDto.NumLine != nil || *logSystemDto.NumLine > 0 {
numLine = *logSystemDto.NumLine
}
logLines, total, err := util.ReadLinesFromBottom(constant.SystemLogPath, numLine)
if err != nil {
vo.Fail("Unable to read log file", c)
return
}
for _, line := range logLines {
if line == "" {
continue
}
logSystemVo := vo.LogSystemVo{}
err := json.Unmarshal([]byte(line), &logSystemVo)
if err != nil {
vo.Fail("Unable to unmarshal log data", c)
continue
}
logSystemVos = append(logSystemVos, logSystemVo)
}
vo.Success(vo.LogSystemPage[vo.LogSystemVo]{
LogSystemVos: logSystemVos,
Total: int64(total),
}, c)
}
func LogHysteria2(c *gin.Context) {
logSystemDto, err := validateField(c, dto.LogDto{})
if err != nil {
return
}
exists := util.Exists(constant.Hysteria2LogPath)
logHysteria2Vos := make([]vo.LogHysteria2Vo, 0)
if !exists {
vo.Success(logHysteria2Vos, c)
return
}
numLine := 0
if logSystemDto.NumLine != nil || *logSystemDto.NumLine > 0 {
numLine = *logSystemDto.NumLine
}
logLines, total, err := util.ReadLinesFromBottom(constant.Hysteria2LogPath, numLine)
if err != nil {
vo.Fail("Unable to read log file", c)
return
}
for _, line := range logLines {
if line == "" {
continue
}
logHysteria2Vo := vo.LogHysteria2Vo{}
err := json.Unmarshal([]byte(line), &logHysteria2Vo)
if err != nil {
vo.Fail("Unable to unmarshal log data", c)
continue
}
logHysteria2Vos = append(logHysteria2Vos, logHysteria2Vo)
}
vo.Success(vo.LogSystemPage[vo.LogHysteria2Vo]{
LogSystemVos: logHysteria2Vos,
Total: int64(total),
}, c)
}
func ExportLog(c *gin.Context) {
logExportDto, err := validateField(c, dto.LogExportDto{})
if err != nil {
return
}
var fileName string
var filePath string
if *logExportDto.Option == 0 {
fileName = fmt.Sprintf("hy2xs-admin-%s.log", time.Now().Format("20060102150405"))
filePath = constant.SystemLogPath
} else if *logExportDto.Option == 1 {
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
filePath = constant.Hysteria2LogPath
}
if !util.Exists(filePath) {
vo.Fail("log file not exist", c)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.File(filePath)
}
+25
View File
@@ -0,0 +1,25 @@
package controller
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
func MonitorSystem(c *gin.Context) {
systemMonitorVo, err := service.MonitorSystem()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(systemMonitorVo, c)
}
func MonitorHysteria2(c *gin.Context) {
hysteria2MonitorVo, err := service.MonitorHysteria2()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(hysteria2MonitorVo, c)
}
+41
View File
@@ -0,0 +1,41 @@
package controller
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"net/http"
"regexp"
)
var validate *validator.Validate
func init() {
validate = validator.New()
_ = validate.RegisterValidation("validateStr", validateStr)
}
func validateStr(f validator.FieldLevel) bool {
field := f.Field().String()
// 字符串必须6-32位是字母或者数字或部分特殊字符的组合
reg := "^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$"
compile := regexp.MustCompile(reg)
return field == "" || compile.MatchString(field)
}
func validateField[T interface{}](c *gin.Context, field T) (T, error) {
if c.Request.Method == http.MethodGet {
_ = c.ShouldBindQuery(&field)
} else if c.Request.Method == http.MethodPost ||
c.Request.Method == http.MethodPut ||
c.Request.Method == http.MethodDelete {
_ = c.ShouldBindJSON(&field)
}
if err := validate.Struct(&field); err != nil {
vo.Fail(constant.InvalidError, c)
return field, fmt.Errorf(constant.InvalidError)
}
return field, nil
}
+119
View File
@@ -0,0 +1,119 @@
package dao
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"time"
)
func SaveAccount(account entity.Account) (int64, error) {
if tx := sqliteDB.Save(&account); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
}
return *account.Id, nil
}
func DeleteAccount(ids []int64) error {
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Account{}); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func UpdateAccount(ids []int64, updates map[string]interface{}) error {
if len(updates) > 0 {
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Account{}).
Where("id in ?", ids).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
}
return nil
}
func UpsertAccount(accounts []entity.Account) error {
if tx := sqliteDB.Model(&entity.Account{}).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "username"}},
DoUpdates: clause.AssignmentColumns([]string{"pass", "con_pass", "quota", "download", "upload", "expire_time", "kick_util_time", "device_no", "role", "deleted", "create_time", "update_time", "login_at", "con_at", "remark"}),
}).Create(accounts); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func UpdateAccountTraffic(username string, download int64, upload int64) error {
if upload != 0 || download != 0 {
updates := map[string]interface{}{}
if download != 0 {
updates["download"] = gorm.Expr("download + ?", download)
}
if upload != 0 {
updates["upload"] = gorm.Expr("upload + ?", upload)
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Account{}).
Where("username = ?", username).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
}
return nil
}
func GetAccount(query interface{}, args ...interface{}) (entity.Account, error) {
var account entity.Account
if tx := sqliteDB.Model(&entity.Account{}).
Where(query, args...).First(&account); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return account, errors.New(constant.WrongPassword)
}
logrus.Errorf("%v", tx.Error)
return account, errors.New(constant.SysError)
}
return account, nil
}
func PageAccount(accountPageDto dto.AccountPageDto) ([]entity.Account, int64, error) {
var accounts []entity.Account
var total int64
tx := sqliteDB.Model(&entity.Account{})
if accountPageDto.Username != nil && *accountPageDto.Username != "" {
tx.Where("username like ?", fmt.Sprintf("%%%s%%", *accountPageDto.Username))
}
if accountPageDto.Deleted != nil {
tx.Where("deleted = ?", *accountPageDto.Deleted)
}
if accountPageDto.Remark != nil && *accountPageDto.Remark != "" {
tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *accountPageDto.Remark))
}
tx.Count(&total)
if tx.Scopes(Paginate(accountPageDto.PageNum, accountPageDto.PageSize)).
Order("role,create_time desc").
Find(&accounts); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return accounts, 0, errors.New(constant.SysError)
}
return accounts, total, nil
}
func ListAccount(query interface{}, args ...interface{}) ([]entity.Account, error) {
var accounts []entity.Account
if tx := sqliteDB.Model(&entity.Account{}).
Where(query, args...).Order("role,create_time desc").Find(&accounts); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return accounts, errors.New(constant.SysError)
}
return accounts, nil
}
+66
View File
@@ -0,0 +1,66 @@
package dao
import (
"errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"time"
)
func SaveConfig(config entity.Config) (int64, error) {
if tx := sqliteDB.Save(&config); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
}
return *config.Id, nil
}
func UpdateConfig(keys []string, updates map[string]interface{}) error {
if len(updates) > 0 {
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Config{}).
Where("key in ?", keys).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
}
return nil
}
func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) {
var config entity.Config
if tx := sqliteDB.Model(&entity.Config{}).
Where(query, args...).First(&config); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return config, errors.New(constant.ConfigNotExist)
}
logrus.Errorf("%v", tx.Error)
return config, errors.New(constant.SysError)
}
return config, nil
}
func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error) {
var configs []entity.Config
if tx := sqliteDB.Model(&entity.Config{}).
Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return configs, errors.New(constant.SysError)
}
return configs, nil
}
func UpsertConfig(configs []entity.Config) error {
if tx := sqliteDB.Model(&entity.Config{}).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "remark", "create_time", "update_time"}),
}).Create(configs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package dao
import (
"errors"
"fmt"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"hy2xs-admin/model/constant"
"log"
"os"
"strings"
"time"
)
var sqlInitStr = "CREATE TABLE IF NOT EXISTS account\n(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n username TEXT NOT NULL UNIQUE DEFAULT '',\n pass TEXT NOT NULL DEFAULT '',\n con_pass TEXT NOT NULL DEFAULT '',\n quota INTEGER NOT NULL DEFAULT 0,\n download INTEGER NOT NULL DEFAULT 0,\n upload INTEGER NOT NULL DEFAULT 0,\n expire_time INTEGER NOT NULL DEFAULT 0,\n kick_util_time INTEGER NOT NULL DEFAULT 0,\n device_no INTEGER NOT NULL DEFAULT 3,\n role TEXT NOT NULL DEFAULT 'user',\n deleted INTEGER NOT NULL DEFAULT 0,\n create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\nALTER TABLE account\n ADD COLUMN login_at INTEGER NOT NULL DEFAULT 0;\nALTER TABLE account\n ADD COLUMN con_at INTEGER NOT NULL DEFAULT 0;\nALTER TABLE account\n ADD COLUMN remark INTEGER NOT NULL DEFAULT '';\nCREATE INDEX IF NOT EXISTS account_deleted_index ON account (deleted);\nCREATE INDEX IF NOT EXISTS account_username_index ON account (username);\nCREATE INDEX IF NOT EXISTS account_con_pass_index ON account (con_pass);\nCREATE INDEX IF NOT EXISTS account_pass_index ON account (pass);\nINSERT INTO account (id, username, pass, con_pass, quota, download, upload, expire_time, device_no, role)\nSELECT 1 ,'sysadmin', '02f382b76ca1ab7aa06ab03345c7712fd5b971fb0c0f2aef98bac9cd', 'sysadmin.sysadmin', -1, 0, 0, 253370736000000, 6, 'admin'\n WHERE NOT EXISTS (SELECT 1 FROM account WHERE id = 1);\nCREATE TABLE IF NOT EXISTS config\n(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n key TEXT NOT NULL UNIQUE DEFAULT '',\n value TEXT NOT NULL DEFAULT '',\n remark TEXT NOT NULL DEFAULT '',\n create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX IF NOT EXISTS config_key_index ON config (key);\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_WEB_PORT', '8081', 'HY2XS admin Web Port'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_PORT');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_WEB_CONTEXT', '/', 'HY2XS admin Web Context'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_CONTEXT');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_CRT_PATH', '', 'HY2XS admin CRT File Path'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_CRT_PATH');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_KEY_PATH', '', 'HY2XS admin KEY File Path'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_KEY_PATH');\nINSERT INTO config (key, value, remark)\nSELECT 'JWT_SECRET', hex(randomblob(10)), 'JWT Secret'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'JWT_SECRET');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_ENABLE', '0', 'Hysteria2 Switch'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_ENABLE');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG', '', 'Hysteria2 Config'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_TRAFFIC_TIME', '1', 'Hysteria2 Traffic Time'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_TRAFFIC_TIME');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG_REMARK', '', 'Hysteria2 Config Remark'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_REMARK');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG_PORT_HOPPING', '', 'Hysteria2 Config Port Hopping'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_PORT_HOPPING');\nINSERT INTO config (key, value, remark)\nSELECT 'RESET_TRAFFIC_CRON', '', 'Reset Traffic Cron'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'RESET_TRAFFIC_CRON');\nINSERT INTO config (key, value, remark)\nSELECT 'CLASH_EXTENSION', '', 'Clash Subscription Extension'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'CLASH_EXTENSION');"
var sqliteDB *gorm.DB
func InitSqliteDB() error {
var err error
sqliteDB, err = gorm.Open(sqlite.Open(fmt.Sprintf("%s%s", os.Getenv("HUI_DATA"), constant.SqliteDBPath)), &gorm.Config{
TranslateError: true,
Logger: logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Silent,
IgnoreRecordNotFoundError: true,
ParameterizedQueries: true,
Colorful: false,
},
),
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
})
if err != nil {
logrus.Errorf("sqlite open err: %v", err)
return errors.New("sqlite open err")
}
return nil
}
func InitSql(port string) error {
if err := InitSqliteDB(); err != nil {
return err
}
if err := sqliteInit(sqlInitStr); err != nil {
return err
}
if port != "" {
var result string
db, err := sqliteDB.DB()
if err != nil {
return err
}
if err := db.QueryRow("SELECT value from config where key = 'H_UI_WEB_PORT' limit 1").Scan(&result); err != nil {
logrus.Errorf("sqlite exec err: %v", err)
return errors.New("sqlite exec err")
}
if result == "8081" {
if tx := sqliteDB.Exec("UPDATE config set value = ? where key = 'H_UI_WEB_PORT'", port); tx.Error != nil {
logrus.Errorf("sqlite exec err: %v", tx.Error)
return errors.New("sqlite exec err")
}
}
}
return nil
}
func sqliteInit(sqlStr string) error {
if sqliteDB != nil {
sqls := strings.Split(strings.Replace(sqlStr, "\r\n", "\n", -1), ";\n")
for _, s := range sqls {
s = strings.TrimSpace(s)
if s != "" {
tx := sqliteDB.Exec(s)
if tx.Error != nil && !strings.HasPrefix(tx.Error.Error(), "SQL logic error: duplicate column name") {
logrus.Errorf("sqlite exec err: %v", tx.Error)
return errors.New("sqlite exec err")
}
}
}
}
return nil
}
func CloseSqliteDB() error {
if sqliteDB != nil {
db, err := sqliteDB.DB()
if err != nil {
logrus.Errorf("sqlite err: %v", err)
return errors.New("sqlite err")
}
if err = db.Close(); err != nil {
logrus.Errorf("sqlite close err: %v", err)
return errors.New("sqlite close err")
}
}
return nil
}
func Paginate(pageNum *int64, pageSize *int64) func(db *gorm.DB) *gorm.DB {
var num int64 = 1
var size int64 = 10
if pageNum != nil && *pageNum > 0 {
num = *pageNum
}
if pageSize != nil && *pageSize > 0 {
size = *pageSize
}
return func(db *gorm.DB) *gorm.DB {
return db.Offset(int((num - 1) * size)).Limit(int(size))
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+76
View File
@@ -0,0 +1,76 @@
CREATE TABLE IF NOT EXISTS account
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE DEFAULT '',
pass TEXT NOT NULL DEFAULT '',
con_pass TEXT NOT NULL DEFAULT '',
quota INTEGER NOT NULL DEFAULT 0,
download INTEGER NOT NULL DEFAULT 0,
upload INTEGER NOT NULL DEFAULT 0,
expire_time INTEGER NOT NULL DEFAULT 0,
kick_util_time INTEGER NOT NULL DEFAULT 0,
device_no INTEGER NOT NULL DEFAULT 3,
role TEXT NOT NULL DEFAULT 'user',
deleted INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE account
ADD COLUMN login_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE account
ADD COLUMN con_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE account
ADD COLUMN remark INTEGER NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS account_deleted_index ON account (deleted);
CREATE INDEX IF NOT EXISTS account_username_index ON account (username);
CREATE INDEX IF NOT EXISTS account_con_pass_index ON account (con_pass);
CREATE INDEX IF NOT EXISTS account_pass_index ON account (pass);
INSERT INTO account (id, username, pass, con_pass, quota, download, upload, expire_time, device_no, role)
SELECT 1 ,'sysadmin', '02f382b76ca1ab7aa06ab03345c7712fd5b971fb0c0f2aef98bac9cd', 'sysadmin.sysadmin', -1, 0, 0, 253370736000000, 6, 'admin'
WHERE NOT EXISTS (SELECT 1 FROM account WHERE id = 1);
CREATE TABLE IF NOT EXISTS config
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE DEFAULT '',
value TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS config_key_index ON config (key);
INSERT INTO config (key, value, remark)
SELECT 'H_UI_WEB_PORT', '8081', 'HY2XS admin Web Port'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_PORT');
INSERT INTO config (key, value, remark)
SELECT 'H_UI_WEB_CONTEXT', '/', 'HY2XS admin Web Context'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_CONTEXT');
INSERT INTO config (key, value, remark)
SELECT 'H_UI_CRT_PATH', '', 'HY2XS admin CRT File Path'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_CRT_PATH');
INSERT INTO config (key, value, remark)
SELECT 'H_UI_KEY_PATH', '', 'HY2XS admin KEY File Path'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_KEY_PATH');
INSERT INTO config (key, value, remark)
SELECT 'JWT_SECRET', hex(randomblob(10)), 'JWT Secret'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'JWT_SECRET');
INSERT INTO config (key, value, remark)
SELECT 'HYSTERIA2_ENABLE', '0', 'Hysteria2 Switch'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_ENABLE');
INSERT INTO config (key, value, remark)
SELECT 'HYSTERIA2_CONFIG', '', 'Hysteria2 Config'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG');
INSERT INTO config (key, value, remark)
SELECT 'HYSTERIA2_TRAFFIC_TIME', '1', 'Hysteria2 Traffic Time'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_TRAFFIC_TIME');
INSERT INTO config (key, value, remark)
SELECT 'HYSTERIA2_CONFIG_REMARK', '', 'Hysteria2 Config Remark'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_REMARK');
INSERT INTO config (key, value, remark)
SELECT 'HYSTERIA2_CONFIG_PORT_HOPPING', '', 'Hysteria2 Config Port Hopping'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_PORT_HOPPING');
INSERT INTO config (key, value, remark)
SELECT 'RESET_TRAFFIC_CRON', '', 'Reset Traffic Cron'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'RESET_TRAFFIC_CRON');
INSERT INTO config (key, value, remark)
SELECT 'CLASH_EXTENSION', '', 'Clash Subscription Extension'
WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'CLASH_EXTENSION');
+14
View File
@@ -0,0 +1,14 @@
# http://editorconfig.org
root = true
# 表示所有文件适用
[*]
charset = utf-8 # 设置文件字符集为 utf-8
end_of_line = lf # 控制换行类型(lf | cr | crlf)
indent_style = tab # 缩进风格(tab | space
insert_final_newline = true # 始终在文件末尾插入一个新行
# 表示仅 md 文件适用以下规则
[*.md]
max_line_length = off # 关闭最大行长度限制
trim_trailing_whitespace = false # 关闭末尾空格修剪
+8
View File
@@ -0,0 +1,8 @@
## 开发环境
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV='development'
VITE_APP_TITLE = 'h-ui'
VITE_APP_PORT = 3000
VITE_APP_BASE_API = '/hui'
+5
View File
@@ -0,0 +1,5 @@
## 生产环境
VITE_APP_TITLE = 'h-ui'
VITE_APP_PORT = 80
VITE_APP_BASE_API = '/hui'
+13
View File
@@ -0,0 +1,13 @@
dist
node_modules
public
.vscode
.idea
*.sh
*.md
src/assets
.eslintrc.cjs
.prettierrc.cjs
.stylelintrc.cjs
+269
View File
@@ -0,0 +1,269 @@
{
"globals": {
"EffectScope": true,
"ElForm": true,
"ElMessage": true,
"ElMessageBox": true,
"ElTree": true,
"asyncComputed": true,
"autoResetRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createReactiveFn": true,
"createSharedComposable": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"pausableWatch": true,
"provide": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveDirective": true,
"resolveRef": true,
"resolveUnref": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchEffect": true,
"watchIgnorable": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
}
}
+32
View File
@@ -0,0 +1,32 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
parser: "vue-eslint-parser", // https://eslint.vuejs.org/user-guide/#bundle-configurations
extends: [
"eslint:recommended",
"plugin:vue/vue3-essential",
"plugin:@typescript-eslint/recommended",
"./.eslintrc-auto-import.json",
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
parser: "@typescript-eslint/parser",
},
plugins: ["vue", "@typescript-eslint"],
rules: {
"vue/multi-word-component-names": "off", // 关闭组件名必须多字: https://eslint.vuejs.org/rules/multi-word-component-names.html
"@typescript-eslint/no-empty-function": "off", // 关闭空方法检查
"@typescript-eslint/no-explicit-any": "off", // 关闭any类型的警告
"vue/no-v-model-argument": "off",
"@typescript-eslint/no-non-null-assertion": "off",
}, // https://eslint.org/docs/latest/use/configure/language-options#specifying-globals
globals: {
DialogType: "readonly",
OptionType: "readonly",
},
};
цц
+14
View File
@@ -0,0 +1,14 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.local
+9
View File
@@ -0,0 +1,9 @@
dist
node_modules
public
.vscode
.idea
*.sh
*.md
src/assets
+36
View File
@@ -0,0 +1,36 @@
module.exports = {
// (x)=>{},单个参数箭头函数是否显示小括号。(always:始终显示;avoid:省略括号。默认:always)
arrowParens: "always",
// 开始标签的右尖括号是否跟随在最后一行属性末尾,默认false
bracketSameLine: false,
// 对象字面量的括号之间打印空格 (true - Example: { foo: bar } ; false - Example: {foo:bar})
bracketSpacing: true,
// 是否格式化一些文件中被嵌入的代码片段的风格(auto|off;默认auto)
embeddedLanguageFormatting: "auto",
// 指定 HTML 文件的空格敏感度 (css|strict|ignore;默认css)
htmlWhitespaceSensitivity: "css",
// 当文件已经被 Prettier 格式化之后,是否会在文件顶部插入一个特殊的 @format 标记,默认false
insertPragma: false,
// 在 JSX 中使用单引号替代双引号,默认false
jsxSingleQuote: false,
// 每行最多字符数量,超出换行(默认80)
printWidth: 80,
// 超出打印宽度 (always | never | preserve )
proseWrap: "preserve",
// 对象属性是否使用引号(as-needed | consistent | preserve;默认as-needed:对象的属性需要加引号才添加;)
quoteProps: "as-needed",
// 是否只格式化在文件顶部包含特定注释(@prettier| @format)的文件,默认false
requirePragma: false,
// 结尾添加分号
semi: true,
// 使用单引号 (true:单引号;false:双引号)
singleQuote: false,
// 缩进空格数,默认2个空格
tabWidth: 2,
// 元素末尾是否加逗号,默认es5: ES5中的 objects, arrays 等会添加逗号,TypeScript 中的 type 后不加逗号
trailingComma: "es5",
// 指定缩进方式,空格或tab,默认false,即使用空格
useTabs: false,
// vue 文件中是否缩进 <style> 和 <script> 标签,默认 false
vueIndentScriptAndStyle: false,
};
+9
View File
@@ -0,0 +1,9 @@
dist
node_modules
public
.vscode
.idea
*.sh
*.md
src/assets
+43
View File
@@ -0,0 +1,43 @@
module.exports = {
// 继承推荐规范配置
extends: [
"stylelint-config-standard",
"stylelint-config-recommended-scss",
"stylelint-config-recommended-vue/scss",
"stylelint-config-html/vue",
"stylelint-config-recess-order",
],
// 指定不同文件对应的解析器
overrides: [
{
files: ["**/*.{vue,html}"],
customSyntax: "postcss-html",
},
{
files: ["**/*.{css,scss}"],
customSyntax: "postcss-scss",
},
],
// 自定义规则
rules: {
"import-notation": "string", // 指定导入CSS文件的方式("string"|"url")
"selector-class-pattern": null, // 选择器类名命名规则
"custom-property-pattern": null, // 自定义属性命名规则
"keyframes-name-pattern": null, // 动画帧节点样式命名规则
"no-descending-specificity": null, // 允许无降序特异性
// 允许 global 、export 、deep伪类
"selector-pseudo-class-no-unknown": [
true,
{
ignorePseudoClasses: ["global", "export", "deep"],
},
],
// 允许未知属性
"property-no-unknown": [
true,
{
ignoreProperties: ["menuBg", "menuText", "menuActiveText"],
},
],
},
};
+3
View File
@@ -0,0 +1,3 @@
# Frontend
HY2XS admin frontend
+75
View File
@@ -0,0 +1,75 @@
package frontend
import (
"embed"
"fmt"
"github.com/gin-gonic/gin"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed dist/*
var staticFiles embed.FS
func InitFrontend(router *gin.Engine, relativePath string) {
router.GET(relativePath, func(c *gin.Context) {
indexHTML, err := staticFiles.ReadFile("dist/index.html")
if err != nil {
c.String(http.StatusInternalServerError, "Internal Server Error")
return
}
c.Data(http.StatusOK, "text/html", []byte(replaceRelativePaths(string(indexHTML), relativePath)))
})
router.GET(path.Join(relativePath, "favicon.ico"), func(c *gin.Context) {
indexHTML, err := staticFiles.ReadFile("dist/favicon.ico")
if err != nil {
c.String(http.StatusInternalServerError, "Internal Server Error")
return
}
c.Data(http.StatusOK, "image/x-icon", indexHTML)
})
router.StaticFS(path.Join(relativePath, "assets"), http.FS(getStaticFS()))
router.NoRoute(func(c *gin.Context) {
filePath := c.Request.URL.Path
if relativePath != "/" && !strings.HasPrefix(filePath, relativePath) {
c.String(http.StatusNotFound, "404")
return
}
fileContent, err := getFileContent(filePath)
if err != nil {
c.String(http.StatusNotFound, "404")
return
}
c.Data(http.StatusOK, http.DetectContentType(fileContent), fileContent)
})
}
func getStaticFS() fs.FS {
staticFs, _ := fs.Sub(staticFiles, "dist/assets")
return staticFs
}
func getFileContent(filePath string) ([]byte, error) {
fileContent, err := staticFiles.ReadFile(fmt.Sprintf("dist%s", filePath))
if err != nil {
return nil, err
}
return fileContent, nil
}
func replaceRelativePaths(htmlContent string, basePath string) string {
if basePath == "/" {
basePath = ""
}
htmlContent = strings.ReplaceAll(htmlContent, "/__dynamic_base__/", basePath+"/")
injection := fmt.Sprintf(`
<script>
window.__dynamic_base__ = "%s";
</script>`, basePath)
return strings.Replace(htmlContent, "</head>", injection+"</head>", 1)
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="HY2XS admin" />
<meta name="keywords" content="HY2XS,HY2XS admin,Hysteria2" />
<title>HY2XS admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
{
"name": "hy2xs-admin",
"version": "0.0.0",
"private": true,
"description": "HY2XS admin",
"author": "HY2XS",
"scripts": {
"dev": "vite serve --mode development",
"build:prod": "vite build --mode production && vue-tsc --noEmit",
"lint:eslint": "eslint --fix --ext .ts,.js,.vue ./src ",
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^1.0.0",
"@vueuse/core": "^9.1.1",
"axios": "^1.3.4",
"copy-to-clipboard": "^3.3.3",
"echarts": "^5.2.2",
"element-plus": "^2.3.1",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"path-to-regexp": "^6.2.0",
"pinia": "^2.0.33",
"screenfull": "^6.0.0",
"vue": "^3.2.45",
"vue-i18n": "9",
"vue-router": "^4.1.6",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@iconify-json/ep": "^1.1.8",
"@types/nprogress": "^0.2.0",
"@types/path-browserify": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"@vitejs/plugin-vue": "^4.0.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.9.0",
"fast-glob": "^3.2.11",
"postcss": "^8.4.21",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.7",
"sass": "^1.58.3",
"stylelint": "^15.5.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.0.0",
"stylelint-config-recommended-scss": "^9.0.1",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^32.0.0",
"stylelint-config-standard-scss": "^8.0.0",
"typescript": "^4.9.3",
"unocss": "^0.50.1",
"unplugin-auto-import": "^0.13.0",
"unplugin-icons": "^0.15.1",
"unplugin-vue-components": "^0.23.0",
"vite": "^4.3.1",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^0.35.0",
"vite-plugin-dynamic-base": "1.0.2"
},
"engines": {
"node": ">=18.12.0"
}
}
+6232
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { ElConfigProvider } from "element-plus";
import { useAppStore } from "@/store/modules/app";
const appStore = useAppStore();
</script>
<template>
<el-config-provider :locale="appStore.locale" :size="appStore.size">
<router-view />
</el-config-provider>
</template>
+155
View File
@@ -0,0 +1,155 @@
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import {
AccountSaveDto,
AccountInfo,
AccountLoginDto,
AccountLoginVo,
AccountPageDto,
AccountUpdateDto,
AccountVo,
} from "./types";
/**
* 查询
*/
export function getAccountApi(data: IdDto): AxiosPromise<AccountVo> {
return request({
url: "/account/getAccount",
method: "get",
params: data,
});
}
/**
* 保存
*
* @param data
*/
export function saveAccountApi(data: AccountSaveDto): AxiosPromise {
return request({
url: "/account/saveAccount",
method: "post",
data: data,
});
}
/**
* 查询当前
*/
export function getAccountInfoApi(): AxiosPromise<AccountInfo> {
return request({
url: "/account/getAccountInfo",
method: "get",
});
}
/**
* 分页
* @param data
*/
export function pageAccountApi(
data: AccountPageDto
): AxiosPromise<PageVo<AccountVo>> {
return request({
url: "/account/pageAccount",
method: "get",
params: data,
});
}
/**
* 删除
*
* @param data
*/
export function deleteAccountApi(data: IdDto): AxiosPromise {
return request({
url: "/account/deleteAccount",
method: "post",
data: data,
});
}
/**
* 修改
* @param data
*/
export function updateAccountApi(data: AccountUpdateDto): AxiosPromise {
return request({
url: "/account/updateAccount",
method: "post",
data: data,
});
}
/**
* 重设流量
* @param data
*/
export function resetTrafficApi(data: IdDto): AxiosPromise {
return request({
url: "/account/resetTraffic",
method: "post",
data: data,
});
}
/**
* 登录
* @param data
*/
export function loginApi(data: AccountLoginDto): AxiosPromise<AccountLoginVo> {
return request({
url: "/auth/login",
method: "post",
data: data,
});
}
/**
* 导入
*/
export function importAccountApi(data: FormData): AxiosPromise {
return request({
url: "/account/importAccount",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: data,
});
}
/**
* 导出
*/
export function exportAccountApi(): AxiosPromise {
return request({
url: "/account/exportAccount",
method: "post",
responseType: "blob",
});
}
/**
* 解除下线状态
*/
export function releaseKickAccountApi(data: IdDto): AxiosPromise {
return request({
url: "/account/releaseKickAccount",
method: "post",
data: data,
});
}
/**
* 是否默认密码
* @param data
*/
export function verifyDefaultPassApi(): AxiosPromise {
return request({
url: "/account/verifyDefaultPass",
method: "get",
});
}
+79
View File
@@ -0,0 +1,79 @@
export interface AccountPageDto extends BaseDto {
username?: string;
deleted?: number;
remark?: string;
}
export interface AccountUpdateDto extends IdDto {
username: string;
pass: string;
conPass: string;
quota: number;
expireTime: number;
deviceNo: number;
deleted: number;
remark: string;
}
export interface AccountSaveDto {
username: string;
pass: string;
conPass: string;
quota: number;
expireTime: number;
deviceNo: number;
deleted: number;
remark: string;
}
export interface AccountLoginDto {
username: string;
pass: string;
}
export interface AccountVo extends IdDto {
username: string;
quota: number;
download: number;
upload: number;
expireTime: number;
kickUtilTime: number;
deviceNo: number;
role: string;
deleted: number;
createTime: string;
online: boolean;
device: number;
loginAt: number;
conAt: number;
remark: string;
}
export interface AccountLoginVo {
accessToken: string;
tokenType: string;
}
export interface AccountInfo {
id: number;
username: string;
roles: string[];
}
export interface AccountForm extends IdDto {
username: string;
pass: string;
conPass: string;
quota: number;
expireTime: number;
deviceNo: number;
deleted: number;
remark: string;
}
export interface KickAccountForm {
ids: number[];
kickUtilTime: number;
}
+112
View File
@@ -0,0 +1,112 @@
import { AxiosPromise } from "axios";
import request from "@/utils/request";
import {
ConfigDto,
ConfigsDto,
ConfigUpdateDto,
ConfigVo,
Hysteria2AcmePathVo,
Hysteria2ServerConfig,
} from "@/api/config/types";
export function getHysteria2ConfigApi(): AxiosPromise<Hysteria2ServerConfig> {
return request({
url: "/config/getHysteria2Config",
method: "get",
});
}
export function updateHysteria2ConfigApi(
data: Hysteria2ServerConfig
): AxiosPromise {
return request({
url: "/config/updateHysteria2Config",
method: "post",
data: data,
});
}
export function getConfigApi(data: ConfigDto): AxiosPromise<ConfigVo> {
return request({
url: "/config/getConfig",
method: "get",
params: data,
});
}
export function listConfigApi(data: ConfigsDto): AxiosPromise<Array<ConfigVo>> {
return request({
url: "/config/listConfig",
method: "post",
data: data,
});
}
export function updateConfigsApi(data: ConfigUpdateDto): AxiosPromise {
return request({
url: "/config/updateConfigs",
method: "post",
data: data,
});
}
export function exportConfigApi(): AxiosPromise {
return request({
url: "/config/exportConfig",
method: "post",
responseType: "blob",
});
}
export function importConfigApi(data: FormData): AxiosPromise {
return request({
url: "/config/importConfig",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: data,
});
}
export function exportHysteria2ConfigApi(): AxiosPromise {
return request({
url: "/config/exportHysteria2Config",
method: "post",
responseType: "blob",
});
}
export function importHysteria2ConfigApi(data: FormData): AxiosPromise {
return request({
url: "/config/importHysteria2Config",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: data,
});
}
export function hysteria2AcmePathApi(): AxiosPromise<Hysteria2AcmePathVo> {
return request({
url: "/config/hysteria2AcmePath",
method: "get",
});
}
export function restartServerApi(): AxiosPromise {
return request({
url: "/config/restartServer",
method: "post",
});
}
export function uploadCertFileApi(data: FormData): AxiosPromise<string> {
return request({
url: "/config/uploadCertFile",
method: "post",
data,
headers: { "Content-Type": "multipart/form-data" },
});
}
+302
View File
@@ -0,0 +1,302 @@
export interface ConfigDto {
key: string;
}
export interface ConfigsDto {
keys: Array<string>;
}
export interface ConfigVo {
key: string;
value: string;
}
export interface ConfigsUpdateDto {
key: string;
value: string;
}
export interface ConfigUpdateDto {
configUpdateDtos: Array<ConfigsUpdateDto>;
}
export interface Hysteria2ServerConfig {
listen: string;
tls?: {
cert: string;
key: string;
sniGuard?: string;
};
acme?: {
domains: string[];
email: string;
ca: string;
listenHost: string;
dir: string;
type?: string;
http?: {
altPort: number;
};
tls?: {
altPort: number;
};
dns?: {
name: string;
config: { [key: string]: string };
};
disableHTTP: boolean;
disableTLSALPN: boolean;
altHTTPPort: number;
altTLSALPNPort: number;
};
obfs?: {
type: string;
salamander: {
password: string;
};
};
quic?: {
initStreamReceiveWindow?: number;
maxStreamReceiveWindow?: number;
initConnReceiveWindow?: number;
maxConnReceiveWindow?: number;
maxIdleTimeout?: string;
maxIncomingStreams?: number;
disablePathMTUDiscovery?: boolean;
};
bandwidth?: {
up: string;
down: string;
};
ignoreClientBandwidth?: boolean;
speedTest?: boolean;
disableUDP?: boolean;
udpIdleTimeout?: string;
resolver?: {
type: string;
tcp?: {
addr: string;
timeout: string;
};
udp?: {
addr: string;
timeout: string;
};
tls?: {
addr: string;
timeout: string;
sni: string;
insecure: boolean;
};
https?: {
addr: string;
timeout: string;
sni: string;
insecure: boolean;
};
};
sniff?: {
enable: boolean;
timeout: string;
rewriteDomain: boolean;
tcpPorts?: string;
udpPorts?: string;
};
acl?: {
file?: string;
inline?: string[];
geoip?: string;
geosite?: string;
geoUpdateInterval?: string;
};
outbounds?: Hysteria2ServerConfigOutbound[];
trafficStats: {
listen: string;
};
masquerade?: {
type: string;
file?: {
dir: string;
};
proxy?: {
url: string;
rewriteHost: boolean;
insecure: boolean;
};
string?: {
content: string;
headers?: { [key: string]: string };
statusCode?: number;
};
listenHTTP?: string;
listenHTTPS?: string;
forceHTTPS?: boolean;
};
}
export const defaultHysteria2ServerConfig: Hysteria2ServerConfig = {
listen: ":443",
tls: {
cert: "",
key: "",
sniGuard: "",
},
acme: {
domains: [],
email: "",
ca: "zerossl",
listenHost: "0.0.0.0",
dir: "my_acme_dir",
type: "",
http: {
altPort: 8888,
},
tls: {
altPort: 44333,
},
dns: {
name: "gomommy",
config: {},
},
disableHTTP: false,
disableTLSALPN: false,
altHTTPPort: 80,
altTLSALPNPort: 443,
},
obfs: {
type: "salamander",
salamander: {
password: "cry_me_a_r1ver",
},
},
quic: {
initStreamReceiveWindow: 8388608,
maxStreamReceiveWindow: 8388608,
initConnReceiveWindow: 20971520,
maxConnReceiveWindow: 20971520,
maxIdleTimeout: "30s",
maxIncomingStreams: 1024,
disablePathMTUDiscovery: false,
},
bandwidth: {
up: "1 gbps",
down: "1 gbps",
},
ignoreClientBandwidth: false,
speedTest: false,
disableUDP: false,
udpIdleTimeout: "60s",
resolver: {
type: "",
tcp: {
addr: "8.8.8.8:53",
timeout: "4s",
},
udp: {
addr: "8.8.4.4:53",
timeout: "4s",
},
tls: {
addr: "1.1.1.1:853",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
https: {
addr: "1.1.1.1:443",
timeout: "10s",
sni: "cloudflare-dns.com",
insecure: false,
},
},
sniff: {
enable: true,
timeout: "2s",
rewriteDomain: false,
tcpPorts: "80,443,8000-9000",
udpPorts: "all",
},
acl: {
file: "",
inline: [],
geoip: "",
geosite: "",
geoUpdateInterval: "168h",
},
outbounds: [],
trafficStats: {
listen: ":9999",
},
masquerade: {
type: "",
file: {
dir: "",
},
proxy: {
url: "",
rewriteHost: true,
insecure: false,
},
string: {
content: "hello stupid world",
headers: {},
statusCode: 200,
},
listenHTTP: ":80",
listenHTTPS: ":443",
forceHTTPS: true,
},
};
export interface Hysteria2ServerConfigOutbound {
name: string;
type: string;
socks5?: {
addr: string;
username?: string;
password?: string;
};
http?: {
url: string;
insecure: boolean;
};
direct?: {
mode: string;
bindIPv4?: string;
bindIPv6?: string;
bindDevice?: string;
fastOpen?: boolean;
};
}
export const defaultHysteria2ServerConfigOutbound: Hysteria2ServerConfigOutbound =
{
name: "",
type: "socks5",
socks5: {
addr: "",
username: undefined,
password: undefined,
},
http: {
url: "",
insecure: false,
},
direct: {
mode: "auto",
bindIPv4: undefined,
bindIPv6: undefined,
bindDevice: undefined,
fastOpen: false,
},
};
export interface Tab {
name: string;
desc: string;
}
export interface Hysteria2AcmePathVo {
crtPath: string;
keyPath: string;
}
+40
View File
@@ -0,0 +1,40 @@
import { AxiosPromise } from "axios";
import { Hysteria2ServerConfig } from "@/api/config/types";
import request from "@/utils/request";
import {
Hysteria2KickDto,
Hysteria2SubscribeVo,
Hysteria2SubscribeUrlDto,
Hysteria2UrlDto,
Hysteria2UrlVo,
} from "@/api/hysteria2/types";
export function hysteria2KickApi(
data: Hysteria2KickDto
): AxiosPromise<Hysteria2ServerConfig> {
return request({
url: "/hysteria2/hysteria2Kick",
method: "post",
data: data,
});
}
export function hysteria2SubscribeUrlApi(
dto: Hysteria2SubscribeUrlDto
): AxiosPromise<Hysteria2SubscribeVo> {
return request({
url: "/hysteria2/hysteria2SubscribeUrl",
method: "get",
params: dto,
});
}
export function hysteria2UrlApi(
dto: Hysteria2UrlDto
): AxiosPromise<Hysteria2UrlVo> {
return request({
url: "/hysteria2/hysteria2Url",
method: "get",
params: dto,
});
}
+26
View File
@@ -0,0 +1,26 @@
export interface Hysteria2KickDto {
ids: number[];
kickUtilTime: number;
}
export interface Hysteria2SubscribeUrlDto {
accountId: number;
protocol: string;
host: string;
}
export interface Hysteria2UrlDto {
accountId: number;
hostname: string;
}
export interface Hysteria2SubscribeVo {
url: string;
qrCode: string;
}
export interface Hysteria2UrlVo {
url: string;
qrCode: string;
}
+35
View File
@@ -0,0 +1,35 @@
import { AxiosPromise } from "axios";
import request from "@/utils/request";
import {
LogDto,
LogExportDto,
LogHysteria2Vo,
LogSystemVo,
} from "@/api/log/types";
export function logSystemApi(data: LogDto): AxiosPromise<PageVo<LogSystemVo>> {
return request({
url: "/log/logSystem",
method: "get",
params: data,
});
}
export function logHysteria2Api(
data: LogDto
): AxiosPromise<PageVo<LogHysteria2Vo>> {
return request({
url: "/log/logHysteria2",
method: "get",
params: data,
});
}
export function exportLogApi(data: LogExportDto): AxiosPromise {
return request({
url: "/log/exportLog",
method: "post",
data: data,
responseType: "blob",
});
}
+24
View File
@@ -0,0 +1,24 @@
export interface LogDto {
numLine: number;
}
export interface LogExportDto {
option: number;
}
export interface LogSystemVo {
clientIp: string;
latencyTime: string;
level: string;
msg: string;
reqMethod: string;
reqUri: string;
statusCode: string;
time: string;
}
export interface LogHysteria2Vo {
level: string;
msg: string;
time: string;
}
+17
View File
@@ -0,0 +1,17 @@
import { AxiosPromise } from "axios";
import request from "@/utils/request";
import { Hysteria2MonitorVo, SystemMonitorVo } from "@/api/monitor/types";
export function monitorSystemApi(): AxiosPromise<SystemMonitorVo> {
return request({
url: "/monitor/monitorSystem",
method: "get",
});
}
export function monitorHysteria2Api(): AxiosPromise<Hysteria2MonitorVo> {
return request({
url: "/monitor/monitorHysteria2",
method: "get",
});
}
+13
View File
@@ -0,0 +1,13 @@
export interface SystemMonitorVo {
huiVersion: string;
cpuPercent: number;
diskPercent: number;
memPercent: number;
}
export interface Hysteria2MonitorVo {
userTotal: number;
deviceTotal: number;
version: string;
running: boolean;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 36 36"><path d="M19.41 18l8.29-8.29a1 1 0 0 0-1.41-1.41L18 16.59l-8.29-8.3a1 1 0 0 0-1.42 1.42l8.3 8.29l-8.3 8.29A1 1 0 1 0 9.7 27.7l8.3-8.29l8.29 8.29a1 1 0 0 0 1.41-1.41z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 395 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 36 36"><path d="M26 17H10a1 1 0 0 0 0 2h16a1 1 0 0 0 0-2z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 279 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M7 12l7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 12l7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M21 12H7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" ></path><path d="M3 3v18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>

After

Width:  |  Height:  |  Size: 647 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 20 20"><path d="M3 5h14V3H3v2zm12 8V7H5v6h10zM3 17h14v-2H3v2z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g transform="translate(24 0) scale(-1 1)"><g fill="none"><path d="M7 12l7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 12l7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M21 12H7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path><path d="M3 3v18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 693 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714739812671" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2441" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M798.72 819.2H682.666667v-68.266667h116.053333c80.213333 0 145.066667-64.853333 145.066667-145.066666S878.933333 460.8 798.72 460.8H750.933333v-34.133333c0-131.413333-107.52-238.933333-238.933333-238.933334s-238.933333 107.52-238.933333 238.933334v34.133333h-37.546667c-80.213333 0-145.066667 64.853333-145.066667 145.066667S155.306667 750.933333 235.52 750.933333H341.333333v68.266667h-105.813333C117.76 819.2 20.48 723.626667 20.48 605.866667c0-107.52 80.213333-197.973333 184.32-211.626667C221.866667 240.64 353.28 119.466667 512 119.466667s288.426667 119.466667 305.493333 274.773333c109.226667 10.24 194.56 100.693333 194.56 211.626667-1.706667 117.76-97.28 213.333333-213.333333 213.333333z" fill="#191919" p-id="2442"></path><path d="M482.986667 515.413333h68.266666v307.2h-68.266666z" fill="#00C97C" p-id="2443"></path><path d="M515.413333 901.12l-150.186666-148.48 47.786666-49.493333 102.4 102.4 100.693334-102.4 47.786666 49.493333z" fill="#00C97C" p-id="2444"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720229787" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8983" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 720m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="8984" fill="#000000"></path><path d="M480 416v184c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V416c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8z" p-id="8985" fill="#000000"></path><path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48z m-783.5-27.9L512 239.9l339.8 588.2H172.2z" p-id="8986" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 768 B

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M49.217 41.329l-.136-35.24c-.06-2.715-2.302-4.345-5.022-4.405h-3.65c-2.712-.06-4.866 2.303-4.806 5.016l.152 19.164-24.151-23.79a6.698 6.698 0 0 0-9.499 0 6.76 6.76 0 0 0 0 9.526l23.93 23.713-18.345.074c-2.712-.069-5.228 1.813-5.64 5.02v3.462c.069 2.721 2.31 4.97 5.022 5.03l35.028-.207c.052.005.087.025.133.025l2.457.054a4.626 4.626 0 0 0 3.436-1.38c.88-.874 1.205-2.096 1.169-3.462l-.262-2.465c0-.048.182-.081.182-.136h.002zm52.523 51.212l18.32-.073c2.713.06 5.224-1.609 5.64-4.815v-3.462c-.068-2.722-2.317-4.97-5.021-5.04l-34.58.21c-.053 0-.086-.021-.138-.021l-2.451-.06a4.64 4.64 0 0 0-3.445 1.381c-.885.868-1.201 2.094-1.174 3.46l.27 2.46c.005.06-.177.095-.177.141l.141 34.697c.069 2.713 2.31 4.338 5.022 4.397l3.45.006c2.705.062 4.867-2.31 4.8-5.026l-.153-18.752 24.151 23.946a6.69 6.69 0 0 0 9.494 0 6.747 6.747 0 0 0 0-9.523L101.74 92.54v.001zM48.125 80.662a4.636 4.636 0 0 0-3.437-1.382l-2.457.06c-.05 0-.082.022-.137.022l-35.025-.21c-2.712.07-4.957 2.318-5.022 5.04v3.462c.409 3.206 2.925 4.874 5.633 4.814l18.554.06-24.132 23.928c-2.62 2.626-2.62 6.89 0 9.524a6.694 6.694 0 0 0 9.496 0l24.155-23.79-.155 18.866c-.06 2.722 2.094 5.093 4.801 5.025h3.65c2.72-.069 4.962-1.685 5.022-4.406l.141-34.956c0-.05-.182-.082-.182-.136l.262-2.46c.03-1.366-.286-2.592-1.166-3.46h-.001zM80.08 47.397a4.62 4.62 0 0 0 3.443 1.374l2.45-.054c.055 0 .088-.02.143-.028l35.08.21c2.712-.062 4.953-2.312 5.021-5.033l.009-3.463c-.417-3.211-2.937-5.084-5.64-5.025l-18.615-.073 23.917-23.715c2.63-2.623 2.63-6.879.008-9.513a6.691 6.691 0 0 0-9.494 0L92.251 26.016l.155-19.312c.065-2.713-2.097-5.085-4.802-5.025h-3.45c-2.713.069-4.954 1.693-5.022 4.406l-.139 35.247c0 .054.18.088.18.136l-.267 2.465c-.028 1.366.288 2.588 1.174 3.463v.001z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714739827633" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2765" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M512 989.866667C249.173333 989.866667 34.133333 774.826667 34.133333 512S249.173333 34.133333 512 34.133333s477.866667 215.04 477.866667 477.866667-215.04 477.866667-477.866667 477.866667z m0-887.466667C286.72 102.4 102.4 286.72 102.4 512s184.32 409.6 409.6 409.6 409.6-184.32 409.6-409.6S737.28 102.4 512 102.4z" fill="#191919" p-id="2766"></path><path d="M363.52 725.333333l-44.373333-51.2 158.72-143.36V238.933333h68.266666v322.56z" fill="#00C97C" p-id="2767"></path></svg>

After

Width:  |  Height:  |  Size: 807 B

@@ -0,0 +1 @@
<svg class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="128" height="128"><defs><style/></defs><path d="M512 128q69.675 0 135.51 21.163t115.498 54.997 93.483 74.837 73.685 82.006 51.67 74.837 32.17 54.827L1024 512q-2.347 4.992-6.315 13.483T998.87 560.17t-31.658 51.669-44.331 59.99-56.832 64.34-69.504 60.16-82.347 51.5-94.848 34.687T512 896q-69.675 0-135.51-21.163t-115.498-54.826-93.483-74.326-73.685-81.493-51.67-74.496-32.17-54.997L0 513.707q2.347-4.992 6.315-13.483t18.816-34.816 31.658-51.84 44.331-60.33 56.832-64.683 69.504-60.331 82.347-51.84 94.848-34.816T512 128.085zm0 85.333q-46.677 0-91.648 12.331t-81.152 31.83-70.656 47.146-59.648 54.485-48.853 57.686-37.675 52.821-26.325 43.99q12.33 21.674 26.325 43.52t37.675 52.351 48.853 57.003 59.648 53.845T339.2 767.02t81.152 31.488T512 810.667t91.648-12.331 81.152-31.659 70.656-46.848 59.648-54.186 48.853-57.344 37.675-52.651T927.957 512q-12.33-21.675-26.325-43.648t-37.675-52.65-48.853-57.345-59.648-54.186-70.656-46.848-81.152-31.659T512 213.334zm0 128q70.656 0 120.661 50.006T682.667 512 632.66 632.661 512 682.667 391.339 632.66 341.333 512t50.006-120.661T512 341.333zm0 85.334q-35.328 0-60.33 25.002T426.666 512t25.002 60.33T512 597.334t60.33-25.002T597.334 512t-25.002-60.33T512 426.666z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg width="128" height="64" xmlns="http://www.w3.org/2000/svg"><path d="M127.072 7.994c1.37-2.208.914-5.152-.914-6.87-2.056-1.717-4.797-1.226-6.396.982-.229.245-25.586 32.382-55.74 32.382-29.24 0-55.74-32.382-55.968-32.627-1.6-1.963-4.57-2.208-6.397-.49C-.17 3.086-.399 6.275 1.2 8.238c.457.736 5.94 7.36 14.62 14.72L4.17 35.96c-1.828 1.963-1.6 5.152.228 6.87.457.98 1.6 1.471 2.742 1.471s2.284-.49 3.198-1.472l12.564-13.983c5.94 4.416 13.021 8.587 20.788 11.53l-4.797 17.418c-.685 2.699.686 5.397 3.198 6.133h1.37c2.057 0 3.884-1.472 4.341-3.68L52.6 42.83c3.655.736 7.538 1.227 11.422 1.227 3.883 0 7.767-.49 11.422-1.227l4.797 17.173c.457 2.208 2.513 3.68 4.34 3.68.457 0 .914 0 1.143-.246 2.513-.736 3.883-3.434 3.198-6.133l-4.797-17.172c7.767-2.944 14.848-7.114 20.788-11.53l12.336 13.738c.913.981 2.056 1.472 3.198 1.472s2.284-.49 3.198-1.472c1.828-1.963 1.828-4.906.228-6.87l-11.65-13.001c9.366-7.36 14.849-14.474 14.849-14.474z"/></svg>

After

Width:  |  Height:  |  Size: 944 B

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M38.47 52L52 38.462l-23.648-23.67L43.209 0H.035L0 43.137l14.757-14.865L38.47 52zm74.773 47.726L89.526 76 76 89.536l23.648 23.672L84.795 128h43.174L128 84.863l-14.757 14.863zM89.538 52l23.668-23.648L128 43.207V.038L84.866 0 99.73 14.76 76 38.472 89.538 52zM38.46 76L14.792 99.651 0 84.794v43.173l43.137.033-14.865-14.757L52 89.53 38.46 76z"/></svg>

After

Width:  |  Height:  |  Size: 421 B

@@ -0,0 +1 @@
<svg t="1650814907622" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="52318" width="200" height="200"><path d="M958.400956 451.54921c-0.058328-5.760191-2.597151-11.215436-6.965645-14.97097L524.345166 69.511143c-7.498788-6.445806-18.581194-6.445806-26.079982 0L309.582871 231.6755l0-102.017488c0-11.04966-8.901741-19.532869-19.951401-19.532869l-88.034009 0c-11.048637 0-19.928888 8.482185-19.928888 19.532869l0 211.954343L71.176063 436.57824c-4.423753 3.800559-6.967692 9.341762-6.967692 15.173584l0 105.500822c0 7.819083 4.554736 14.921851 11.660574 18.183128 2.670829 1.226944 5.51562 1.824555 8.343015 1.824555 4.699022 0 9.346879-1.654686 13.048177-4.836145l53.29788-45.825698 0 324.100516c0 60.677964 49.364291 110.042255 110.042255 110.042255L764.792447 960.741257c60.677964 0 110.042255-49.364291 110.042255-110.042255L874.834702 527.026228l51.585889 44.335764c5.955642 5.119601 14.356986 6.282077 21.481244 2.965541 7.122211-3.313465 11.645225-10.488889 11.565407-18.342764L958.400956 451.54921zM221.578538 150.034085l48.095391 0 0 115.941616-48.095391 41.336454L221.578538 150.034085zM570.718333 920.725892 436.666244 920.725892 436.666244 700.642404c0-11.031241 8.976442-20.007683 20.007683-20.007683l94.0357 0c11.031241 0 20.007683 8.976442 20.007683 20.007683L570.71731 920.725892zM834.818313 495.895207l0 354.803795c0 38.612413-31.414477 70.02689-70.02689 70.02689l-154.058748 0L610.732675 700.642404c0-33.096792-26.926256-60.023048-60.023048-60.023048l-94.0357 0c-33.096792 0-60.023048 26.926256-60.023048 60.023048l0 220.084511L260.59925 920.726915c-38.612413 0-70.02689-31.414477-70.02689-70.02689L190.57236 495.895207c0-1.172709-0.121773-2.314719-0.315178-3.432169l322.113255-276.958846 322.70268 277.348726C834.921667 493.848595 834.818313 494.858598 834.818313 495.895207zM525.411451 173.947727c-7.502881-6.445806-18.587334-6.446829-26.086122 0.00307L104.223736 513.663896l0-52.726875 407.081439-349.870436 407.176606 349.9523 0.521886 51.205219L525.411451 173.947727z" p-id="52319"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720422565" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15443" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M235.5 871.691v-740h98v304h385v-304h98v740h-98v-349h-385v349h-98z" p-id="15444" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 440 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745205151" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9116" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M336 421m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="9117" fill="#000000"></path><path d="M688 421m-48 0a48 48 0 1 0 96 0 48 48 0 1 0-96 0Z" p-id="9118" fill="#000000"></path><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m263 711c-34.2 34.2-74 61-118.3 79.8C611 874.2 562.3 884 512 884c-50.3 0-99-9.8-144.8-29.2-44.3-18.7-84.1-45.6-118.3-79.8-34.2-34.2-61-74-79.8-118.3C149.8 611 140 562.3 140 512s9.8-99 29.2-144.8c18.7-44.3 45.6-84.1 79.8-118.3 34.2-34.2 74-61 118.3-79.8C413 149.8 461.7 140 512 140c50.3 0 99 9.8 144.8 29.2 44.3 18.7 84.1 45.6 118.3 79.8 34.2 34.2 61 74 79.8 118.3C874.2 413 884 461.7 884 512s-9.8 99-29.2 144.8c-18.7 44.3-45.6 84.1-79.8 118.2z" p-id="9119" fill="#000000"></path><path d="M664 533h-48.1c-4.2 0-7.8 3.2-8.1 7.4C604 589.9 562.5 629 512 629s-92.1-39.1-95.8-88.6c-0.3-4.2-3.9-7.4-8.1-7.4H360c-4.6 0-8.2 3.8-8 8.4 4.4 84.3 74.5 151.6 160 151.6s155.6-67.3 160-151.6c0.2-4.6-3.4-8.4-8-8.4z" p-id="9120" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg t="1675576810577" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1553" width="200" height="200"><path d="M379.392 460.8l114.688 114.688-42.496 102.4L307.2 532.48l-168.96 168.96-71.68-72.704L234.496 460.8l-45.056-45.056c-27.136-27.136-51.2-66.56-66.56-108.544h112.64c7.68 14.336 16.896 27.136 26.112 35.84l45.568 46.08 45.056-45.056C382.976 312.32 409.6 247.808 409.6 204.8H0V102.4h256V0h102.4v102.4h256v102.4h-102.4c0 70.144-37.888 161.28-87.04 210.944L378.88 460.8z m196.608 409.6L512 1024H409.6l256-614.4h102.4l256 614.4h-102.4l-64-153.6h-281.6z m42.496-102.4h196.608L716.8 532.48 618.496 768z" p-id="1554" data-spm-anchor-id="a313x.7781069.0.i0" class="selected"></path></svg>

After

Width:  |  Height:  |  Size: 730 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720786193" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10390" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M688 312v-48c0-4.4-3.6-8-8-8H296c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h384c4.4 0 8-3.6 8-8zM296 400c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h184c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8H296z" p-id="10391" fill="#000000"></path><path d="M440 852H208V148h560v344c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V108c0-17.7-14.3-32-32-32H168c-17.7 0-32 14.3-32 32v784c0 17.7 14.3 32 32 32h272c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" p-id="10392" fill="#000000"></path><path d="M885.7 903.5l-93.3-93.3C814.7 780.7 828 743.9 828 704c0-97.2-78.8-176-176-176s-176 78.8-176 176 78.8 176 176 176c35.8 0 69-10.7 96.8-29l94.7 94.7c1.6 1.6 3.6 2.3 5.6 2.3s4.1-0.8 5.6-2.3l31-31c3.1-3.1 3.1-8.1 0-11.2zM652 816c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z" p-id="10393" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755103595" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8918" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M193 796c0 17.7 14.3 32 32 32h574c17.7 0 32-14.3 32-32V563c0-176.2-142.8-319-319-319S193 386.8 193 563v233z m72-233c0-136.4 110.6-247 247-247s247 110.6 247 247v193H404V585c0-5.5-4.5-10-10-10h-44c-5.5 0-10 4.5-10 10v171h-75V563zM216.9 310.5l39.6-39.6c3.1-3.1 3.1-8.2 0-11.3l-67.9-67.9c-3.1-3.1-8.2-3.1-11.3 0l-39.6 39.6c-3.1 3.1-3.1 8.2 0 11.3l67.9 67.9c3.1 3.1 8.1 3.1 11.3 0zM886.5 231.3l-39.6-39.6c-3.1-3.1-8.2-3.1-11.3 0l-67.9 67.9c-3.1 3.1-3.1 8.2 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l67.9-67.9c3.1-3.2 3.1-8.2 0-11.3zM832 892H192c-17.7 0-32 14.3-32 32v24c0 4.4 3.6 8 8 8h688c4.4 0 8-3.6 8-8v-24c0-17.7-14.3-32-32-32zM484 180h56c4.4 0 8-3.6 8-8V76c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v96c0 4.4 3.6 8 8 8z" p-id="8919" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1575802846045" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2750" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M868.593046 403.832442c-30.081109-28.844955-70.037123-44.753273-112.624057-44.753273L265.949606 359.079168c-42.554188 0-82.510202 15.908318-112.469538 44.690852-30.236652 28.782533-46.857191 67.222007-46.857191 108.198258l0 294.079782c0 40.977273 16.619516 79.414701 46.702672 108.136859 29.959336 28.844955 70.069869 44.814672 112.624057 44.814672l490.019383 0c42.585911 0 82.696444-15.969717 112.624057-44.814672 30.082132-28.844955 46.579875-67.222007 46.579875-108.136859L915.172921 511.968278C915.171897 471.053426 898.675178 432.677397 868.593046 403.832442zM841.821309 806.049083c0 22.098297-8.882298 42.772152-25.099654 58.306964-16.154935 15.661701-37.81935 24.203238-60.752666 24.203238L265.949606 888.559285c-22.934339 0-44.567032-8.54256-60.877509-24.264637-16.186657-15.474436-25.067932-36.148291-25.067932-58.246589L180.004165 511.968278c0-22.035876 8.881274-42.772152 25.192775-58.307987 16.186657-15.536858 37.81935-24.139793 60.753689-24.139793l490.019383 0c22.933315 0 44.597731 8.602935 60.752666 24.139793 16.21838 15.535835 25.099654 36.272112 25.099654 58.307987L841.822332 806.049083zM510.974136 135.440715c114.914216 0 208.318536 89.75214 208.318536 200.055338l73.350588 0c0-149.113109-126.366036-270.496667-281.669124-270.496667-155.333788 0-281.699824 121.383558-281.699824 270.496667l73.350588 0C302.623877 225.193879 396.059919 135.440715 510.974136 135.440715zM474.299865 747.244792l73.350588 0L547.650453 629.576859l-73.350588 0L474.299865 747.244792z" p-id="2751"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714739843967" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2926" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M510.293333 972.8L61.44 460.8 235.52 51.2h551.253333l174.08 409.6-450.56 512zM141.653333 448.853333L510.293333 870.4l370.346667-421.546667L740.693333 119.466667h-460.8L141.653333 448.853333z" fill="#191919" p-id="2927"></path><path d="M510.293333 631.466667L332.8 431.786667l49.493333-44.373334 128 141.653334 129.706667-141.653334 49.493333 44.373334z" fill="#00C97C" p-id="2928"></path></svg>

After

Width:  |  Height:  |  Size: 725 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 512 512"><path d="M400 148l-21.12-24.57A191.43 191.43 0 0 0 240 64C134 64 48 150 48 256s86 192 192 192a192.09 192.09 0 0 0 181.07-128" fill="none" stroke="currentColor" stroke-linecap="square" stroke-miterlimit="10" stroke-width="32"></path><path d="M464 68.45V220a4 4 0 0 1-4 4H308.45a4 4 0 0 1-2.83-6.83L457.17 65.62a4 4 0 0 1 6.83 2.83z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 561 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714720044650" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8586" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M312.1 591.5c3.1 3.1 8.2 3.1 11.3 0l101.8-101.8 86.1 86.2c3.1 3.1 8.2 3.1 11.3 0l226.3-226.5c3.1-3.1 3.1-8.2 0-11.3l-36.8-36.8c-3.1-3.1-8.2-3.1-11.3 0L517 485.3l-86.1-86.2c-3.1-3.1-8.2-3.1-11.3 0L275.3 543.4c-3.1 3.1-3.1 8.2 0 11.3l36.8 36.8z" p-id="8587" fill="#000000"></path><path d="M904 160H548V96c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H120c-17.7 0-32 14.3-32 32v520c0 17.7 14.3 32 32 32h356.4v32L311.6 884.1c-3.7 2.4-4.7 7.3-2.3 11l30.3 47.2v0.1c2.4 3.7 7.4 4.7 11.1 2.3L512 838.9l161.3 105.8c3.7 2.4 8.7 1.4 11.1-2.3v-0.1l30.3-47.2c2.4-3.7 1.3-8.6-2.3-11L548 776.3V744h356c17.7 0 32-14.3 32-32V192c0-17.7-14.3-32-32-32z m-40 512H160V232h704v440z" p-id="8588" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714719706106" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9222" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M924.8 625.7l-65.5-56c3.1-19 4.7-38.4 4.7-57.8s-1.6-38.8-4.7-57.8l65.5-56c10.1-8.6 13.8-22.6 9.3-35.2l-0.9-2.6c-18.1-50.5-44.9-96.9-79.7-137.9l-1.8-2.1c-8.6-10.1-22.5-13.9-35.1-9.5l-81.3 28.9c-30-24.6-63.5-44-99.7-57.6l-15.7-85c-2.4-13.1-12.7-23.3-25.8-25.7l-2.7-0.5c-52.1-9.4-106.9-9.4-159 0l-2.7 0.5c-13.1 2.4-23.4 12.6-25.8 25.7l-15.8 85.4c-35.9 13.6-69.2 32.9-99 57.4l-81.9-29.1c-12.5-4.4-26.5-0.7-35.1 9.5l-1.8 2.1c-34.8 41.1-61.6 87.5-79.7 137.9l-0.9 2.6c-4.5 12.5-0.8 26.5 9.3 35.2l66.3 56.6c-3.1 18.8-4.6 38-4.6 57.1 0 19.2 1.5 38.4 4.6 57.1L99 625.5c-10.1 8.6-13.8 22.6-9.3 35.2l0.9 2.6c18.1 50.4 44.9 96.9 79.7 137.9l1.8 2.1c8.6 10.1 22.5 13.9 35.1 9.5l81.9-29.1c29.8 24.5 63.1 43.9 99 57.4l15.8 85.4c2.4 13.1 12.7 23.3 25.8 25.7l2.7 0.5c26.1 4.7 52.8 7.1 79.5 7.1 26.7 0 53.5-2.4 79.5-7.1l2.7-0.5c13.1-2.4 23.4-12.6 25.8-25.7l15.7-85c36.2-13.6 69.7-32.9 99.7-57.6l81.3 28.9c12.5 4.4 26.5 0.7 35.1-9.5l1.8-2.1c34.8-41.1 61.6-87.5 79.7-137.9l0.9-2.6c4.5-12.3 0.8-26.3-9.3-35zM788.3 465.9c2.5 15.1 3.8 30.6 3.8 46.1s-1.3 31-3.8 46.1l-6.6 40.1 74.7 63.9c-11.3 26.1-25.6 50.7-42.6 73.6L721 702.8l-31.4 25.8c-23.9 19.6-50.5 35-79.3 45.8l-38.1 14.3-17.9 97c-28.1 3.2-56.8 3.2-85 0l-17.9-97.2-37.8-14.5c-28.5-10.8-55-26.2-78.7-45.7l-31.4-25.9-93.4 33.2c-17-22.9-31.2-47.6-42.6-73.6l75.5-64.5-6.5-40c-2.4-14.9-3.7-30.3-3.7-45.5 0-15.3 1.2-30.6 3.7-45.5l6.5-40-75.5-64.5c11.3-26.1 25.6-50.7 42.6-73.6l93.4 33.2 31.4-25.9c23.7-19.5 50.2-34.9 78.7-45.7l37.9-14.3 17.9-97.2c28.1-3.2 56.8-3.2 85 0l17.9 97 38.1 14.3c28.7 10.8 55.4 26.2 79.3 45.8l31.4 25.8 92.8-32.9c17 22.9 31.2 47.6 42.6 73.6L781.8 426l6.5 39.9z" p-id="9223" fill="#000000"></path><path d="M512 326c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176z m79.2 255.2C570 602.3 541.9 614 512 614c-29.9 0-58-11.7-79.2-32.8C411.7 560 400 531.9 400 502c0-29.9 11.7-58 32.8-79.2C454 401.6 482.1 390 512 390c29.9 0 58 11.6 79.2 32.8C612.3 444 624 472.1 624 502c0 29.9-11.7 58-32.8 79.2z" p-id="9224" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714755209531" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9116" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M752 664c-28.5 0-54.8 10-75.4 26.7L469.4 540.8c1.7-9.3 2.6-19 2.6-28.8s-0.9-19.4-2.6-28.8l207.2-149.9C697.2 350 723.5 360 752 360c66.2 0 120-53.8 120-120s-53.8-120-120-120-120 53.8-120 120c0 11.6 1.6 22.7 4.7 33.3L439.9 415.8C410.7 377.1 364.3 352 312 352c-88.4 0-160 71.6-160 160s71.6 160 160 160c52.3 0 98.7-25.1 127.9-63.8l196.8 142.5c-3.1 10.6-4.7 21.8-4.7 33.3 0 66.2 53.8 120 120 120s120-53.8 120-120-53.8-120-120-120z m0-476c28.7 0 52 23.3 52 52s-23.3 52-52 52-52-23.3-52-52 23.3-52 52-52zM312 600c-48.5 0-88-39.5-88-88s39.5-88 88-88 88 39.5 88 88-39.5 88-88 88z m440 236c-28.7 0-52-23.3-52-52s23.3-52 52-52 52 23.3 52 52-23.3 52-52 52z" p-id="9117" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1016 B

+1
View File
@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M0 54.857h54.796v18.286H36.531V128H18.265V73.143H0V54.857zm127.857-36.571H91.935V128H72.456V18.286H36.534V0h91.326l-.003 18.286z"/></svg>

After

Width:  |  Height:  |  Size: 211 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714739816180" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2603" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M798.72 836.266667H682.666667v-68.266667h116.053333c80.213333 0 145.066667-64.853333 145.066667-145.066667S877.226667 477.866667 798.72 477.866667H750.933333v-34.133334c0-131.413333-107.52-238.933333-238.933333-238.933333s-238.933333 107.52-238.933333 238.933333v34.133334h-37.546667c-80.213333 0-145.066667 64.853333-145.066667 145.066666S155.306667 768 235.52 768H341.333333v68.266667h-105.813333C117.76 836.266667 20.48 740.693333 20.48 622.933333c0-107.52 80.213333-197.973333 184.32-211.626666C221.866667 257.706667 353.28 136.533333 512 136.533333s288.426667 119.466667 305.493333 274.773334c109.226667 10.24 194.56 100.693333 194.56 211.626666-1.706667 117.76-97.28 213.333333-213.333333 213.333334z" fill="#191919" p-id="2604"></path><path d="M477.866667 563.2h68.266666v307.2h-68.266666z" fill="#00C97C" p-id="2605"></path><path d="M616.106667 680.96L515.413333 580.266667l-102.4 100.693333-47.786666-47.786667 150.186666-150.186666 148.48 150.186666z" fill="#00C97C" p-id="2606"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745361102" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9516" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M858.5 763.6c-18.9-44.8-46.1-85-80.6-119.5-34.5-34.5-74.7-61.6-119.5-80.6-0.4-0.2-0.8-0.3-1.2-0.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-0.4 0.2-0.8 0.3-1.2 0.5-44.8 18.9-85 46-119.5 80.6-34.5 34.5-61.6 74.7-80.6 119.5C146.9 807.5 137 854 136 901.8c-0.1 4.5 3.5 8.2 8 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c0.1 4.4 3.6 7.8 8 7.8h60c4.5 0 8.1-3.7 8-8.2-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z" p-id="9517" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1714745286527" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9317" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12"><path d="M824.2 699.9c-25.4-25.4-54.7-45.7-86.4-60.4C783.1 602.8 812 546.8 812 484c0-110.8-92.4-201.7-203.2-200-109.1 1.7-197 90.6-197 200 0 62.8 29 118.8 74.2 155.5-31.7 14.7-60.9 34.9-86.4 60.4C345 754.6 314 826.8 312 903.8c-0.1 4.5 3.5 8.2 8 8.2h56c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5C493.8 707.7 551.1 684 612 684c60.9 0 118.2 23.7 161.3 66.8C814.5 792 838 846.3 840 904.3c0.1 4.3 3.7 7.7 8 7.7h56c4.5 0 8.1-3.7 8-8.2-2-77-33-149.2-87.8-203.9zM612 612c-34.2 0-66.4-13.3-90.5-37.5-24.5-24.5-37.9-57.1-37.5-91.8 0.3-32.8 13.4-64.5 36.3-88 24-24.6 56.1-38.3 90.4-38.7 33.9-0.3 66.8 12.9 91 36.6 24.8 24.3 38.4 56.8 38.4 91.4 0 34.2-13.3 66.3-37.5 90.5-24.2 24.2-56.4 37.5-90.6 37.5z" p-id="9318" fill="#000000"></path><path d="M361.5 510.4c-0.9-8.7-1.4-17.5-1.4-26.4 0-15.9 1.5-31.4 4.3-46.5 0.7-3.6-1.2-7.3-4.5-8.8-13.6-6.1-26.1-14.5-36.9-25.1-25.8-25.2-39.7-59.3-38.7-95.4 0.9-32.1 13.8-62.6 36.3-85.6 24.7-25.3 57.9-39.1 93.2-38.7 31.9 0.3 62.7 12.6 86 34.4 7.9 7.4 14.7 15.6 20.4 24.4 2 3.1 5.9 4.4 9.3 3.2 17.6-6.1 36.2-10.4 55.3-12.4 5.6-0.6 8.8-6.6 6.3-11.6-32.5-64.3-98.9-108.7-175.7-109.9-110.9-1.7-203.3 89.2-203.3 199.9 0 62.8 28.9 118.8 74.2 155.5-31.8 14.7-61.1 35-86.5 60.4-54.8 54.7-85.8 126.9-87.8 204-0.1 4.5 3.5 8.2 8 8.2h56.1c4.3 0 7.9-3.4 8-7.7 1.9-58 25.4-112.3 66.7-153.5 29.4-29.4 65.4-49.8 104.7-59.7 3.9-1 6.5-4.7 6-8.7z" p-id="9319" fill="#000000"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1,46 @@
<template>
<div
@click="toggleClick"
class="px-[15px] hover:bg-gray-50 cursor-pointer h-[50px] leading-[50px] dark:hover:bg-[var(--el-fill-color-light)]"
>
<svg
:class="{ 'is-active': isActive }"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
style="color: #fff !important"
>
<path
d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z"
/>
</svg>
</div>
</template>
<script setup lang="ts">
defineProps({
isActive: {
required: true,
type: Boolean,
default: false,
},
});
const emit = defineEmits(["toggleClick"]);
function toggleClick() {
emit("toggleClick");
}
</script>
<style lang="scss" scoped>
.hamburger {
width: 20px;
height: 20px;
vertical-align: -4px;
&.is-active {
transform: rotate(180deg);
}
}
</style>
@@ -0,0 +1,77 @@
<template>
<draggable class="flex gap-2" :list="tags" item-key="id" animation="200">
<template #item="{ element }">
<el-tag closable @close="handleClose(element)" size="large">
{{ element }}
</el-tag>
</template>
<template #footer>
<el-input
v-if="inputVisible"
ref="inputRef"
v-model="tag"
class="w-50"
@keyup.enter="handleConfirm"
@blur="handleConfirm"
/>
<el-button v-else @click="showInput">+</el-button>
</template>
</draggable>
</template>
<script setup lang="ts">
import draggable from "vuedraggable";
import { ElInput } from "element-plus";
import { PropType } from "vue";
const props = defineProps({
tags: {
required: false,
type: Array as PropType<string[]>,
default: () => [],
},
});
const emit = defineEmits<{
(event: "update:tags", value: string[]): void;
}>();
const tags = useVModel(props, "tags", emit);
const inputRef = ref(ElInput);
const state = reactive({
tag: "",
inputVisible: false,
});
const { tag, inputVisible } = toRefs(state);
const showInput = () => {
state.inputVisible = true;
nextTick(() => {
inputRef.value!.input!.focus();
});
};
const handleConfirm = (): void => {
const newTag = state.tag.trim();
if (newTag && !tags.value?.includes(newTag)) {
tags.value?.push(newTag);
state.tag = "";
}
state.inputVisible = false;
};
const handleClose = (tag: string): void => {
const index = tags.value?.indexOf(tag.trim());
if (index !== -1) {
tags.value?.splice(index, 1);
}
};
</script>
<style lang="scss" scoped>
.flex.gap-2 {
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import SvgIcon from "@/components/SvgIcon/index.vue";
import { useAppStore } from "@/store/modules/app";
const appStore = useAppStore();
const { locale } = useI18n();
function handleLanguageChange(lang: string) {
locale.value = lang;
appStore.changeLanguage(lang);
if (lang == "en") {
ElMessage.success("Switch Language Successful!");
} else {
ElMessage.success("Язык переключён");
}
}
</script>
<template>
<el-dropdown trigger="click" @command="handleLanguageChange">
<div>
<svg-icon icon-class="language" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :disabled="appStore.language === 'ru'" command="ru">
Русский
</el-dropdown-item>
<el-dropdown-item :disabled="appStore.language === 'en'" command="en">
English
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
@@ -0,0 +1,180 @@
<template>
<div class="flex gap-2">
<el-tag
:key="key"
v-for="(value, key) in mapObject"
@close="handleClose(key)"
@click="handleInfo(key)"
size="large"
closable
>
{{ key }}
</el-tag>
<el-button @click="handleAdd">+</el-button>
<el-dialog
:title="dialog.title"
v-model="dialog.visible"
width="600px"
append-to-body
@close="closeDialog"
>
<el-form
ref="dataFormRef"
:rules="dataFormRules"
label-position="top"
:model="dataForm"
>
<el-form-item label="key" prop="key">
<el-input v-model="dataForm.key" clearable />
</el-form-item>
<el-form-item label="value" prop="value">
<el-input v-model="dataForm.value" clearable />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm"
>{{ $t("common.confirm") }}
</el-button>
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="infoDialog.title"
v-model="infoDialog.visible"
width="600px"
append-to-body
@close="infoDialog.visible = false"
>
<el-form label-position="top">
<el-form-item label="key" prop="key">
<el-tag>{{ dataInfo.key }}</el-tag>
</el-form-item>
<el-form-item label="value" prop="value">
<el-tag>{{ dataInfo.value }}</el-tag>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="infoDialog.visible = false"
>{{ $t("common.cancel") }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "mapObject",
};
</script>
<script setup lang="ts">
import { PropType } from "vue";
interface Form {
key: string;
value: string;
}
const props = defineProps({
mapObject: {
required: false,
type: Object as PropType<{ [key: string]: string }>,
default: () => ({}),
},
});
const emit = defineEmits<{
(event: "update:mapObject", value: { [key: string]: string }): void;
}>();
const mapObject = useVModel(props, "mapObject", emit);
const dataFormRef = ref(ElForm);
const dataFormRules = {
key: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
],
value: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
],
};
const state = reactive({
dataForm: {
key: "",
value: "",
} as Form,
dialog: {
title: "Add",
visible: false,
} as DialogType,
infoDialog: {
title: "Info",
visible: false,
},
dataInfo: {
key: "",
value: "",
} as Form,
});
const { dataForm, dialog, infoDialog, dataInfo } = toRefs(state);
const handleAdd = () => {
state.dialog.visible = true;
};
const handleClose = (key: string): void => {
delete mapObject.value[key];
};
const handleInfo = (key: string) => {
state.dataInfo = {
key: key,
value: mapObject.value[key] || "",
};
state.infoDialog.visible = true;
};
const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
if (mapObject.value[state.dataForm.key]) {
ElMessage.error("key cannot be repeated");
return;
}
mapObject.value[state.dataForm.key] = state.dataForm.value;
closeDialog();
}
});
};
const closeDialog = (): void => {
state.dialog.visible = false;
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
};
</script>
<style lang="scss" scoped>
.flex.gap-2 {
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,88 @@
<template>
<div :class="'pagination ' + { hidden: hidden }">
<el-pagination
:background="background"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:layout="layout"
:page-sizes="pageSizes"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script setup lang="ts">
import { PropType } from "vue";
import { scrollTo } from "@/utils/scroll-to";
const props = defineProps({
total: {
required: true,
type: Number as PropType<number>,
default: 0,
},
page: {
type: Number,
default: 1,
},
limit: {
type: Number,
default: 20,
},
pageSizes: {
type: Array as PropType<number[]>,
default() {
return [10, 20, 30, 50];
},
},
layout: {
type: String,
default: "total, sizes, prev, pager, next, jumper",
},
background: {
type: Boolean,
default: true,
},
autoScroll: {
type: Boolean,
default: true,
},
hidden: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["pagination"]);
const currentPage = useVModel(props, "page", emit);
const pageSize = useVModel(props, "limit", emit);
function handleSizeChange(val: number) {
emit("pagination", { page: currentPage, limit: val });
if (props.autoScroll) {
scrollTo(0, 800);
}
}
function handleCurrentChange(val: number) {
currentPage.value = val;
emit("pagination", { page: val, limit: props.limit });
if (props.autoScroll) {
scrollTo(0, 800);
}
}
</script>
<style lang="scss" scoped>
.pagination {
padding: 12px;
&.hidden {
display: none;
}
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { addClass, removeClass } from "@/utils/index";
const show = ref(false);
defineProps({
buttonTop: {
default: 250,
type: Number,
},
});
watch(show, (value) => {
if (value) {
addEventClick();
}
if (value) {
addClass(document.body, "showRightPanel");
} else {
removeClass(document.body, "showRightPanel");
}
});
function addEventClick() {
window.addEventListener("click", closeSidebar, { passive: true });
}
function closeSidebar(evt: any) {
//
let parent = evt.target.closest(".right-panel-container");
if (!parent) {
show.value = false;
window.removeEventListener("click", closeSidebar);
}
}
const rightPanel = ref();
function insertToBody() {
const body = document.querySelector("body") as any;
body.insertBefore(rightPanel.value, body.firstChild);
}
onMounted(() => {
insertToBody();
});
onBeforeUnmount(() => {
rightPanel.value.remove();
});
</script>
<template>
<div :class="{ show: show }" ref="rightPanel">
<div class="right-panel-overlay" />
<div class="right-panel-container">
<div
class="right-panel-btn"
:style="{
top: buttonTop + 'px',
}"
@click="show = !show"
>
<i-ep-close v-show="show" />
<i-ep-setting v-show="!show" />
</div>
<div>
<slot />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.showRightPanel {
position: relative;
width: calc(100% - 15px);
overflow: hidden;
}
.right-panel-overlay {
position: fixed;
top: 0;
left: 0;
background: rgb(0 0 0 / 20%);
}
.right-panel-container {
position: fixed;
top: 0;
right: 0;
z-index: 999;
width: 100%;
max-width: 300px;
height: 100vh;
background-color: var(--el-bg-color-overlay);
box-shadow: 0 0 15px 0 rgb(0 0 0 / 5%);
transition: all 0.25s cubic-bezier(0.7, 0.3, 0.1, 1);
transform: translate(100%);
}
.show {
transition: all 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
.right-panel-overlay {
z-index: 99;
width: 100%;
height: 100%;
opacity: 1;
}
.right-panel-container {
transform: translate(0);
}
}
.right-panel-btn {
position: absolute;
left: -36px;
width: 36px;
height: 36px;
color: var(--el-color-white);
text-align: center;
cursor: pointer;
background-color: var(--el-color-primary);
border-radius: 6px 0 0 6px;
svg {
width: 20px;
height: 20px;
vertical-align: -10px;
}
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { useAppStore } from "@/store/modules/app";
const appStore = useAppStore();
const sizeOptions = ref([
{ label: "默认", value: "default" },
{ label: "大型", value: "large" },
{ label: "小型", value: "small" },
]);
function handleSizeChange(size: string) {
appStore.changeSize(size);
ElMessage.success("切换布局大小成功");
}
</script>
<template>
<el-dropdown trigger="click" @command="handleSizeChange">
<div>
<svg-icon icon-class="size" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item of sizeOptions"
:key="item.value"
:disabled="appStore.size == item.value"
:command="item.value"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
@@ -0,0 +1,43 @@
<template>
<svg
aria-hidden="true"
class="svg-icon"
:style="'width:' + size + ';height:' + size"
>
<use :xlink:href="symbolId" :fill="color" />
</svg>
</template>
<script setup lang="ts">
const props = defineProps({
prefix: {
type: String,
default: "icon",
},
iconClass: {
type: String,
required: false,
},
color: {
type: String,
},
size: {
type: String,
default: "1em",
},
});
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
</script>
<style scoped>
.svg-icon {
display: inline-block;
width: 1em;
height: 1em;
overflow: hidden;
vertical-align: -0.15em; /* 因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果 */
outline: none;
fill: currentcolor; /* 定义元素的颜色,currentColor是一个变量,这个变量的值就表示当前元素的color值,如果当前元素未设置color值,则从父元素继承 */
}
</style>
@@ -0,0 +1,74 @@
<template>
<div style="display: flex; align-items: center">
<el-input-number
v-model="capacity"
placeholder="Please enter a value"
:min="-1"
:controls="false"
:precision="0"
clearable
style="width: 220px"
/>
<el-select
v-model="unit"
:placeholder="$t('account.unit')"
style="width: 100px"
>
<el-option
v-for="item in units"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</div>
</template>
<script setup lang="ts">
import { PropType } from "vue";
import {
calculateBytes,
formatStorageCapacity,
formatStorageUnit,
} from "@/utils/byte";
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
const props = defineProps({
valueTmp: {
type: Number as PropType<number>,
required: true,
},
setValue: {
type: Function as PropType<(newValue: number) => void>,
required: true,
},
});
const state = reactive({
capacity: 0,
unit: "GB",
});
const { capacity, unit } = toRefs(state);
watch(
[capacity, unit],
([newC, newU]) => {
const newValue = calculateBytes(newC, newU);
props.setValue(newValue);
},
{ immediate: true }
);
watch(
() => props.valueTmp,
(newValue) => {
state.capacity = formatStorageCapacity(newValue);
state.unit = formatStorageUnit(newValue);
},
{ immediate: true }
);
</script>
<style lang="scss" scoped></style>
+9
View File
@@ -0,0 +1,9 @@
import type { App } from "vue";
import { hasRole } from "./permission";
// 全局注册 directive
export function setupDirective(app: App<Element>) {
// 使 v-hasRole 在所有组件中都可用
app.directive("hasRole", hasRole);
}
@@ -0,0 +1,25 @@
import { useAccountStoreHook } from "@/store/modules/account";
import { Directive, DirectiveBinding } from "vue";
/**
* 角色权限
*/
export const hasRole: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding;
if (value) {
const requiredRoles = value; // DOM绑定需要的角色编码
const { roles } = useAccountStoreHook();
const hasRole = roles.some((perm) => {
return requiredRoles.includes(perm);
});
if (!hasRole) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
throw new Error("need roles! Like v-has-role=\"['admin', 'user']\"");
}
},
};
+24
View File
@@ -0,0 +1,24 @@
import { createI18n } from "vue-i18n";
import { useAppStore } from "@/store/modules/app";
const appStore = useAppStore();
import enLocale from "./package/en";
import ruLocale from "./package/ru";
const messages = {
ru: {
...ruLocale,
},
en: {
...enLocale,
},
};
const i18n = createI18n({
legacy: false,
locale: appStore.language,
messages: messages,
globalInjection: true,
});
export default i18n;
+324
View File
@@ -0,0 +1,324 @@
export default {
// 路由国际化
route: {
account: "Account",
accountList: "Account Manage",
hysteria: "Hysteria",
hysteriaList: "Hysteria Manage",
config: "System",
configList: "System Config",
monitor: "Monitor",
monitorSystem: "System Monitor",
log: "Log",
logSystem: "System Log",
logHysteria: "Hysteria Log",
info: "Info",
infoAccount: "Account Info",
},
// 登录页面国际化
login: {
title: "HY2XS admin",
username: "Username",
password: "Password",
login: "Login",
},
// 导航栏国际化
navbar: {
logout: "Logout",
},
common: {
id: "ID",
createTime: "Create Time",
operate: "Operate",
edit: "Edit",
delete: "Delete",
deleted: "Status",
all: "All",
enable: "Enable",
disable: "Disable",
search: "Search",
reset: "Reset",
add: "Add",
confirm: "Confirm",
cancel: "Cancel",
copySuccess: "Copy successful",
subscribe: "Subscribe",
subscribeQrCode: "Subscribe QR Code",
nodeUrl: "Node URL",
nodeQrCode: "Node QR Code",
resetTraffic: "Reset traffic",
import: "Import",
export: "Export",
save: "Save",
update: "Update",
downloadSuccess: "Download successful",
wait: "The version is being changed, please wait a moment",
enableSuccess: "Hysteria2 start successful",
disableSuccess: "Hysteria2 stop successful",
success: "Success",
refresh: "Refresh",
yes: "Yes",
no: "No",
securityRisk: "Security Risks",
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/account/list?focus=change-pass" style="color: #00BFFF">Click here</a> to change`,
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
},
info: {
expireTime: "y-M-d H:m:s",
greeting1: "The cool and fresh air awakens your energy for the day🌅!",
greeting2: "Good morning",
greeting3: "Good afternoon",
greeting4: "Good evening",
greeting5:
"I want to be a shooting star, cutting through the darkness, just to illuminate your dreams, good night🌛!",
},
account: {
remark: "Remark",
username: "Username",
pass: "Pass",
conPass: "ConPass",
quota: "Quota",
download: "Download",
upload: "Upload",
expireTime: "Expire Time",
kickUtilTimeLast: "Offline Remaining Time",
kickUtilTime: "Offline Time",
deviceNo: "Limit Devices",
onlineStatus: "Online Status",
online: "Online",
offline: "Offline",
device: "Online Devices",
role: "Role",
unit: "Unit",
loginAt: "Last login time",
conAt: "Last connection time",
createTime: "Create Time",
releaseSuccess: "Release successful",
kick: "Kick",
kickTip: "Force user to log off",
releaseKick: "Release",
releaseKickTip: "Remove offline status",
},
config: {
huiWebPort: "HY2XS admin Web Port",
huiWebContext: "HY2XS admin Web Context",
hysteria2TrafficTime: "Hysteria2 Traffic Time",
huiCrtPath: "HY2XS admin CRT File Path",
huiKeyPath: "HY2XS admin KEY File Path",
uploadCrtFile: "Upload CRT File",
uploadKeyFile: "Upload KEY File",
restartServer: "Restart Panel",
restartTip: "Restarting, please refresh",
useHysteria2Cert: "Use Hysteria2 cert",
huiHttps: "Open https on the panel",
resetTrafficCron: "Reset traffic schedule task",
resetTrafficCronTip:
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
resetTrafficMonth: "Run once a month, midnight, first of month",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
},
monitor: {
huiVersion: "HY2XS admin Version",
cpuPercent: "CPU Usage",
memPercent: "Memory Usage",
diskPercent: "Disk Usage",
hysteria2UserTotal: "Number of online users",
hysteria2DeviceTotal: "Number of online devices",
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
hysteria2RunningTrue: "Running",
hysteria2RunningFalse: "Stop",
},
log: {
numLine: "Number of lines",
},
hysteria: {
enable: "Enable",
disable: "Disable",
addConfigItem: "Add Config Item",
hysteria2Version: "Hysteria2 Version",
hysteria2Running: "Hysteria2 Status",
hysteria2ChangeVersion: "Change",
addOutbound: "Add Outbound",
extension: "Extension",
listen: "Listen",
tls: "TLS",
obfs: "Obfuscation",
quic: "QUIC parameters",
bandwidth: "Bandwidth",
speedTest: "Speed Test",
udp: "UDP",
resolver: "Resolver",
sniff: "Protocol Sniffing",
acl: "ACL",
outbounds: "Outbounds",
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Enable/Disable",
remark: "Remark",
portHopping:
"Port Hopping, Multiple individual ports: 1234,5678,9012; A range of ports: 20000-50000; A combination of both: 1234,5000-6000,7044,8000-9000",
clashExtension: "Clash subscription extension",
listen:
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
tlsType: "TLS type",
tls: {
cert: "The path to the Cert file.",
key: "The path to the Key file.",
sniGuard:
'Verify the SNI provided by the client. Accept the connection only when it matches what\'s in the certificate. Terminate the TLS handshake otherwise. Set to strict to enforce this behavior. Set to disable to disable this entirely. The default is dns-san, which enables this feature only when the certificate contains the "Subject Alternative Name" extension with a domain name in it.',
},
acme: {
domains: "Domains",
email: "Email",
ca: "The CA to use. Can be letsencrypt or zerossl.",
listenHost:
"The host address (not including the port) to listen on for the ACME challenge. If omitted, the server will listen on all interfaces.",
dir: "The directory to store the ACME account key and certificates.",
type: "ACME challenge type. Can be http, tls, or dns.",
http: {
altPort:
"Listening port for HTTP challenges. (Note: Changing to a port other than 80 requires port forwarding or HTTP reverse proxy, or the challenge will fail!)",
},
tls: {
altPort:
"Listening port for TLS-ALPN challenges. (Note: Changing to a port other than 443 requires port forwarding or TLS reverse proxy, or the challenge will fail!)",
},
dns: {
name: "DNS provider. For details, refer to ACME DNS Configuration.",
config: "ACME DNS Configuration",
},
disableHTTP: "Disable HTTP challenge.",
disableTLSALPN: "Disable TLS-ALPN challenge.",
altHTTPPort:
"Alternate HTTP challenge port. (Note: If you want to use anything other than 80, you must set up port forward/HTTP reverse proxy from 80 to that port, otherwise ACME will not be able to issue the certificate.)",
altTLSALPNPort:
"Alternate TLS-ALPN challenge port. (Note: If you want to use anything other than 443, you must set up port forward/SNI proxy from 443 to that port, otherwise ACME will not be able to issue the certificate.)",
},
obfs: {
type: "Type",
salamander: {
password: "Replace with a strong password of your choice.",
},
},
quic: {
initStreamReceiveWindow: "The initial QUIC stream receive window size.",
maxStreamReceiveWindow: "The maximum QUIC stream receive window size.",
initConnReceiveWindow:
"The initial QUIC connection receive window size.",
maxConnReceiveWindow:
"The maximum QUIC connection receive window size.",
maxIdleTimeout:
"The maximum idle timeout. How long the server will consider the client still connected without any activity.",
maxIncomingStreams:
"The maximum number of concurrent incoming streams.",
disablePathMTUDiscovery: "Disable QUIC path MTU discovery.",
},
bandwidth: {
up: "Up",
down: "Down",
},
ignoreClientBandwidth:
"When enabled, makes the server to disregard any bandwidth hints set by clients",
speedTest:
"speedTest enables the built-in speed test server. When enabled, clients can test their download and upload speeds with the server. For more information, see the Speed Test documentation.",
disableUDP:
"disableUDP disables UDP forwarding, only allowing TCP connections.",
udpIdleTimeout:
"udpIdleTimeout specifies the amount of time the server will keep a local UDP port open for each UDP session that has no activity. This is conceptually similar to the NAT UDP session timeout.",
resolver: {
type: "Type",
tcp: {
addr: "The address of the TCP resolver.",
timeout: "The timeout for DNS queries.",
},
udp: {
addr: "The address of the UDP resolver.",
timeout: "The timeout for DNS queries.",
},
tls: {
addr: "The address of the TLS resolver.",
timeout: "The timeout for DNS queries.",
sni: "The SNI to use for the TLS resolver.",
insecure: "Disable TLS verification for the TLS resolver.",
},
https: {
addr: "The address of the HTTPS resolver.",
timeout: "The timeout for DNS queries.",
sni: "The SNI to use for the TLS resolver.",
insecure: "Disable TLS verification for the TLS resolver.",
},
},
sniff: {
enable: "Whether to enable protocol sniffing.",
timeout:
"Sniffing timeout. If the protocol/domain cannot be determined within this time, the original address will be used to initiate the connection.",
rewriteDomain:
"Whether to rewrite requests that are already in domain name form. If enabled, requests with the target address already in domain name form will still be sniffed.",
tcpPorts:
"List of TCP ports. Only TCP requests on these ports will be sniffed.",
udpPorts:
"List of UDP ports. Only UDP requests on these ports will be sniffed.",
},
aclType: "ACL type",
acl: {
file: "The path to the ACL file.",
inline: "The list of inline ACL rules.",
geoip:
"Optional. Uncomment to enable. The path to the GeoIP database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
geosite:
"Optional. Uncomment to enable. The path to the GeoSite database file. If this field is omitted, Hysteria will automatically download the latest database to your working directory.",
geoUpdateInterval:
"Optional. The interval at which to refresh the GeoIP/GeoSite databases. 168 hours (1 week) by default. Only applies if the GeoIP/GeoSite databases are automatically downloaded. (Check the note below for more information.)",
},
outbounds: {
name: "The name of the outbound. This is used in ACL rules.",
type: "Type",
socks5: {
addr: "The address of the SOCKS5 proxy.",
username:
"Optional. The username for the SOCKS5 proxy, if authentication is required.",
password:
"Optional. The password for the SOCKS5 proxy, if authentication is required.",
},
http: {
url: "The URL of the HTTP/HTTPS proxy. (Can be http:// or https://)",
insecure:
"Optional. Whether to disable TLS verification. Applies to HTTPS proxies only.",
},
direct: {
mode: "Type",
bindIPv4: "The local IPv4 address to bind to.",
bindIPv6: "The local IPv6 address to bind to.",
bindDevice: "The local network interface to bind to.",
fastOpen: "Enable TCP fast open.",
},
},
trafficStats: {
listen: "The address to listen on.",
},
masquerade: {
type: "Type",
file: {
dir: "The directory to serve files from.",
},
proxy: {
url: "The URL of the website to proxy.",
rewriteHost:
"Whether to rewrite the Host header to match the proxied website. This is required if the target web server uses Host to determine which site to serve.",
insecure: "Disable TLS verification for the proxied website.",
},
string: {
content: "The string to return.",
headers: "Optional. The headers to return.",
statusCode: "Optional. The status code to return. 200 by default.",
},
listenHTTP: "HTTP (TCP) listen address.",
listenHTTPS: "HTTPS (TCP) listen address.",
forceHTTPS:
"Whether to force HTTPS. If enabled, all HTTP requests will be redirected to HTTPS.",
},
},
},
};
+258
View File
@@ -0,0 +1,258 @@
export default {
route: {
account: "Аккаунты",
accountList: "Управление аккаунтами",
hysteria: "Hysteria",
hysteriaList: "Управление Hysteria",
config: "Система",
configList: "Настройки системы",
monitor: "Мониторинг",
monitorSystem: "Системный мониторинг",
log: "Логи",
logSystem: "Системные логи",
logHysteria: "Логи Hysteria",
info: "Информация",
infoAccount: "Профиль",
},
login: {
title: "HY2XS admin",
username: "Логин",
password: "Пароль",
login: "Войти",
},
navbar: {
logout: "Выйти",
},
common: {
id: "ID",
createTime: "Создано",
operate: "Действия",
edit: "Изменить",
delete: "Удалить",
deleted: "Статус",
all: "Все",
enable: "Включено",
disable: "Отключено",
search: "Поиск",
reset: "Сброс",
add: "Добавить",
confirm: "Подтвердить",
cancel: "Отмена",
copySuccess: "Скопировано",
subscribe: "Ссылка подписки",
subscribeQrCode: "QR подписки",
nodeUrl: "URL узла",
nodeQrCode: "QR узла",
resetTraffic: "Сбросить трафик",
import: "Импорт",
export: "Экспорт",
save: "Сохранить",
update: "Обновить",
downloadSuccess: "Загрузка завершена",
wait: "Версия меняется, подождите",
enableSuccess: "Hysteria2 запущена",
disableSuccess: "Hysteria2 остановлена",
success: "Готово",
refresh: "Обновить",
yes: "Да",
no: "Нет",
securityRisk: "Риски безопасности",
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/account/list?focus=change-pass" style="color: #00BFFF">Перейти к смене</a>`,
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
},
info: {
expireTime: "г-М-д Ч:м:с",
greeting1: "Доброе утро,",
greeting2: "Доброе утро,",
greeting3: "Добрый день,",
greeting4: "Добрый вечер,",
greeting5: "Доброй ночи,",
},
account: {
remark: "Комментарий",
username: "Логин",
pass: "Пароль входа",
conPass: "Пароль подключения",
quota: "Квота",
download: "Скачано",
upload: "Отдано",
expireTime: "Срок действия",
kickUtilTimeLast: "Осталось офлайн",
kickUtilTime: "Отключить до",
deviceNo: "Лимит устройств",
onlineStatus: "Онлайн",
online: "Онлайн",
offline: "Офлайн",
device: "Устройства",
role: "Роль",
unit: "Ед. изм.",
loginAt: "Последний вход",
conAt: "Последнее подключение",
createTime: "Создано",
releaseSuccess: "Ограничение снято",
kick: "Отключить",
kickTip: "Принудительно отключить пользователя",
releaseKick: "Снять",
releaseKickTip: "Снять офлайн-статус",
},
config: {
huiWebPort: "Порт HY2XS admin",
huiWebContext: "Web-контекст HY2XS admin",
hysteria2TrafficTime: "Период учёта трафика Hysteria2",
huiCrtPath: "Путь к CRT HY2XS admin",
huiKeyPath: "Путь к KEY HY2XS admin",
uploadCrtFile: "Загрузить CRT",
uploadKeyFile: "Загрузить KEY",
restartServer: "Перезапустить панель",
restartTip: "Перезапуск, обновите страницу",
useHysteria2Cert: "Использовать сертификат Hysteria2",
huiHttps: "Включить HTTPS панели",
resetTrafficCron: "Расписание сброса трафика",
resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
},
monitor: {
huiVersion: "Версия HY2XS admin",
cpuPercent: "CPU",
memPercent: "Память",
diskPercent: "Диск",
hysteria2UserTotal: "Пользователей онлайн",
hysteria2DeviceTotal: "Устройств онлайн",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
hysteria2RunningTrue: "Работает",
hysteria2RunningFalse: "Остановлена",
},
log: {
numLine: "Количество строк",
},
hysteria: {
enable: "Включить",
disable: "Отключить",
addConfigItem: "Добавить параметр",
hysteria2Version: "Версия Hysteria2",
hysteria2Running: "Статус Hysteria2",
hysteria2ChangeVersion: "Сменить",
addOutbound: "Добавить outbound",
extension: "Расширение",
listen: "Адрес прослушивания",
tls: "TLS",
obfs: "Маскировка",
quic: "Параметры QUIC",
bandwidth: "Полоса",
speedTest: "Тест скорости",
udp: "UDP",
resolver: "DNS",
sniff: "Sniffing протоколов",
acl: "ACL",
outbounds: "Outbounds",
http: "Traffic Stats API (HTTP)",
masquerade: "Masquerade",
config: {
enable: "Включить/отключить",
remark: "Комментарий",
portHopping: "Port hopping: отдельные порты, диапазоны или их комбинации",
clashExtension: "Расширение подписки Clash",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип TLS",
tls: {
cert: "Путь к cert-файлу",
key: "Путь к key-файлу",
sniGuard: "Проверка SNI клиента перед принятием TLS-соединения.",
},
acme: {
domains: "Домены",
email: "Email",
ca: "CA: letsencrypt или zerossl",
listenHost: "Адрес для ACME challenge",
dir: "Каталог ACME аккаунта и сертификатов",
type: "Тип ACME challenge: http, tls или dns",
http: { altPort: "Альтернативный порт HTTP challenge" },
tls: { altPort: "Альтернативный порт TLS-ALPN challenge" },
dns: { name: "DNS-провайдер", config: "Конфигурация ACME DNS" },
disableHTTP: "Отключить HTTP challenge",
disableTLSALPN: "Отключить TLS-ALPN challenge",
altHTTPPort: "Альтернативный HTTP-порт",
altTLSALPNPort: "Альтернативный TLS-ALPN-порт",
},
obfs: {
type: "Тип",
salamander: { password: "Сильный пароль Salamander" },
},
quic: {
initStreamReceiveWindow: "Начальное окно приёма QUIC stream",
maxStreamReceiveWindow: "Максимальное окно приёма QUIC stream",
initConnReceiveWindow: "Начальное окно приёма QUIC connection",
maxConnReceiveWindow: "Максимальное окно приёма QUIC connection",
maxIdleTimeout: "Максимальный idle timeout",
maxIncomingStreams: "Максимум входящих stream",
disablePathMTUDiscovery: "Отключить QUIC path MTU discovery",
},
bandwidth: { up: "Вверх", down: "Вниз" },
ignoreClientBandwidth: "Игнорировать bandwidth, заявленный клиентом",
speedTest: "Встроенный сервер теста скорости",
disableUDP: "Отключить UDP forwarding",
udpIdleTimeout: "Idle timeout для UDP-сессий",
resolver: {
type: "Тип",
tcp: { addr: "TCP DNS resolver", timeout: "Timeout DNS-запросов" },
udp: { addr: "UDP DNS resolver", timeout: "Timeout DNS-запросов" },
tls: {
addr: "DNS over TLS resolver",
timeout: "Timeout DNS-запросов",
sni: "SNI для TLS resolver",
insecure: "Отключить TLS-проверку",
},
https: {
addr: "DNS over HTTPS resolver",
timeout: "Timeout DNS-запросов",
sni: "SNI для HTTPS resolver",
insecure: "Отключить TLS-проверку",
},
},
sniff: {
enable: "Включить sniffing",
timeout: "Timeout sniffing",
rewriteDomain: "Повторно анализировать доменные запросы",
tcpPorts: "TCP-порты для sniffing",
udpPorts: "UDP-порты для sniffing",
},
aclType: "Тип ACL",
acl: {
file: "Путь к ACL-файлу",
inline: "Inline ACL-правила",
geoip: "Путь к GeoIP базе",
geosite: "Путь к GeoSite базе",
geoUpdateInterval: "Интервал обновления GeoIP/GeoSite",
},
outbounds: {
name: "Имя outbound",
type: "Тип",
socks5: { addr: "Адрес SOCKS5", username: "Логин SOCKS5", password: "Пароль SOCKS5" },
http: { url: "URL HTTP/HTTPS proxy", insecure: "Отключить TLS-проверку proxy" },
direct: {
mode: "Тип",
bindIPv4: "Локальный IPv4",
bindIPv6: "Локальный IPv6",
bindDevice: "Сетевой интерфейс",
fastOpen: "TCP fast open",
},
},
trafficStats: { listen: "Адрес прослушивания" },
masquerade: {
type: "Тип",
file: { dir: "Каталог файлов" },
proxy: {
url: "URL проксируемого сайта",
rewriteHost: "Переписывать Host header",
insecure: "Отключить TLS-проверку",
},
string: { content: "Ответ строкой", headers: "HTTP headers", statusCode: "HTTP status code" },
listenHTTP: "HTTP listen address",
listenHTTPS: "HTTPS listen address",
forceHTTPS: "Принудительно использовать HTTPS",
},
},
},
};
+312
View File
@@ -0,0 +1,312 @@
export default {
// 路由国际化
route: {
account: "账户",
accountList: "账户管理",
hysteria: "Hysteria",
hysteriaList: "Hysteria 管理",
config: "系统",
configList: "系统设置",
monitor: "监控",
monitorSystem: "系统监控",
log: "日志",
logSystem: "系统日志",
logHysteria: "Hysteria 日志",
info: "信息",
infoAccount: "账户信息",
},
// 登录页面国际化
login: {
title: "HY2XS admin",
username: "用户名",
password: "密码",
login: "登 录",
},
// 导航栏国际化
navbar: {
logout: "注销",
},
common: {
id: "编号",
createTime: "创建时间",
operate: "操作",
edit: "编辑",
delete: "删除",
deleted: "状态",
all: "全部",
enable: "正常",
disable: "禁用",
search: "搜索",
reset: "重设",
add: "新增",
confirm: "确定",
cancel: "取消",
copySuccess: "复制成功",
subscribe: "订阅链接",
subscribeQrCode: "订阅二维码",
nodeUrl: "节点 URL",
nodeQrCode: "节点二维码",
resetTraffic: "重设流量",
import: "导入",
export: "导出",
save: "保存",
update: "更新",
downloadSuccess: "下载成功",
wait: "正在更换版本,请等待一会儿",
enableSuccess: "Hysteria2 启动 !!",
disableSuccess: "Hysteria2 已关闭",
success: "成功",
refresh: "刷新",
yes: "是",
no: "否",
securityRisk: `安全隐患`,
defaultPassTip: `请尽快修改默认登录密码,建议设置强密码以保护您的账户安全。<a href="/#/account/list?focus=change-pass" style="color: #00BFFF">点击这里</a>修改`,
noHttpsTip: `您的网站未启用 HTTPS,数据传输不安全,请尽快开启 HTTPS 以保护用户信息。<a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">点击这里</a>开启`,
},
info: {
expireTime: "年-月-日 时:分:秒",
greeting1: "微凉扑面,清新的空气,唤醒一天的活力🌅!",
greeting2: "上午好,",
greeting3: "下午好,",
greeting4: "晚上好,",
greeting5: "我愿成为流星,划破黑夜,只为照亮你的梦境,晚安🌛!",
},
account: {
remark: "备注",
username: "用户名",
pass: "登录密码",
conPass: "连接密码",
quota: "配额",
download: "下载",
upload: "上传",
expireTime: "过期时间",
kickUtilTimeLast: "下线剩余时间",
kickUtilTime: "下线截止时间",
deviceNo: "限制设备数",
onlineStatus: "在线状态",
online: "在线",
offline: "离线",
device: "在线设备数",
role: "角色",
unit: "单位",
loginAt: "最近登录时间",
conAt: "最近连接时间",
createTime: "注册时间",
releaseSuccess: "解除成功",
kick: "下线",
kickTip: "强制用户下线",
releaseKick: "解除",
releaseKickTip: "解除下线状态",
},
config: {
huiWebPort: "HY2XS admin Web 端口",
huiWebContext: "HY2XS admin Web 上下文",
hysteria2TrafficTime: "Hysteria2 流量倍数",
huiCrtPath: "HY2XS admin CRT 证书路径",
huiKeyPath: "HY2XS admin KEY 证书路径",
uploadCrtFile: "上传 CRT 证书",
uploadKeyFile: "上传 KEY 证书",
restartServer: "重启面板",
restartTip: "正在重启,请刷新",
useHysteria2Cert: "使用 Hysteria2 的证书",
huiHttps: "面板开启 https",
resetTrafficCron: "重设流量计划任务",
resetTrafficCronTip:
"计划任务表达式,参考:https://pkg.go.dev/github.com/robfig/cron/v3",
resetTrafficMonth: "每月运行一次,每月第一天午夜",
resetTrafficWeek: "每周运行一次,周六/周日午夜",
},
monitor: {
huiVersion: "HY2XS admin 版本",
cpuPercent: "CPU 使用率",
memPercent: "内存使用率",
diskPercent: "磁盘使用率",
hysteria2UserTotal: "在线用户数",
hysteria2DeviceTotal: "在线设备数",
hysteria2Version: "Hysteria2 版本",
hysteria2Running: "Hysteria2 状态",
hysteria2RunningTrue: "运行",
hysteria2RunningFalse: "停止",
},
log: {
numLine: "显示行数",
},
hysteria: {
enable: "开启",
disable: "关闭",
addConfigItem: "添加配置项",
hysteria2Version: "Hysteria2 版本",
hysteria2Running: "Hysteria2 状态",
hysteria2ChangeVersion: "切换",
addOutbound: "添加出站规则",
extension: "扩展",
listen: "监听地址",
tls: "TLS",
obfs: "混淆",
quic: "QUIC 参数",
bandwidth: "带宽",
speedTest: "速度测试",
udp: "UDP",
resolver: "DNS 解析",
sniff: "协议嗅探 (Sniff)",
acl: "ACL",
outbounds: "出站规则",
http: "流量统计 API",
masquerade: "伪装",
config: {
enable: "开启/关闭",
remark: "别名",
portHopping:
"端口跳跃,多个单端口:1234,5678,9012;端口范围:20000-50000;两者的组合:1234,5000-6000,7044,8000-9000",
clashExtension: "Clash 订阅扩展",
listen:
"当只有端口没有 IP 地址时,服务器将监听所有可用的 IPv4 和 IPv6 地址。要仅监听 IPv4,可以使用 0.0.0.0:443。要仅监听 IPv6,可以使用 [::]:443。",
tlsType: "TLS 类型",
tls: {
cert: "CERT 路径",
key: "KEY 路径",
sniGuard:
"验证客户端发送的 SNI。 与证书信息匹配时才建立连接, 否则终止 TLS 握手。 设置为 strict 以启用该功能。 设置为 disable 以禁用该功能。 默认为 dns-san, 仅当证书中包含「证书主题背景的备用名称」扩展且该扩展中包含域名时才启用该功能。",
},
acme: {
domains: "域名",
email: "邮箱",
ca: "要使用的 CA。可以是 letsencrypt 或 zerossl。",
listenHost:
"用于 ACME 服务器验证的监听地址(不含端口)。默认监听所有可用的地址。",
dir: "存储 ACME 账户密钥和证书的目录。",
type: "ACME 验证类型。可以是 http, tls 或 dns。",
http: {
altPort:
"用于 HTTP 挑战的监听端口。 (注意: 改为非 80 需要另行配置端口转发或者 HTTP 反向代理,否则证书会签署失败!)",
},
tls: {
altPort:
"用于 TLS-ALPN 挑战的监听端口。 (注意: 改为非 443 需要另行配置端口转发或者 SNI Proxy,否则证书会签署失败!)",
},
dns: {
name: "DNS 提供商。详细信息请参考 ACME DNS 配置。",
config: "ACME DNS 配置",
},
disableHTTP: "禁用 HTTP 挑战。",
disableTLSALPN: "禁用 TLS-ALPN 挑战。",
altHTTPPort:
"用于 HTTP 挑战的监听端口。 (注意: 改为非 80 需要另行配置端口转发或者 HTTP 反向代理,否则证书会签署失败!)",
altTLSALPNPort:
"用于 TLS-ALPN 挑战的监听端口。 (注意: 改为非 443 需要另行配置端口转发或者 SNI Proxy,否则证书会签署失败!)",
},
obfs: {
type: "类型",
salamander: {
password: "替换为你的混淆密码。",
},
},
quic: {
initStreamReceiveWindow: "初始的 QUIC 流接收窗口大小。",
maxStreamReceiveWindow: "最大的 QUIC 流接收窗口大小。",
initConnReceiveWindow: "初始的 QUIC 连接接收窗口大小。",
maxConnReceiveWindow: "最大的 QUIC 连接接收窗口大小。",
maxIdleTimeout:
"最长空闲超时时间。服务器会在多长时间没有收到任何客户端数据后关闭连接。",
maxIncomingStreams: "最大并发传入流的数量。",
disablePathMTUDiscovery: "禁用 MTU 探测。",
},
bandwidth: {
up: "上传",
down: "下载",
},
ignoreClientBandwidth: "忽略客户端带宽设置",
speedTest: "speedTest 启用后,服务端将允许客户端进行下载和上传速度测试。",
disableUDP: "disableUDP 启用后服务端禁用 UDP 转发,只支持 TCP。",
udpIdleTimeout:
"udpIdleTimeout 用于指定服务器对于每个 UDP 会话,在没有流量时保持本地 UDP 端口的时间长度。概念上与 NAT 的 UDP 会话超时时间相似。",
resolver: {
type: "类型",
tcp: {
addr: "TCP DNS 服务器地址。",
timeout: "DNS 查询超时时间。",
},
udp: {
addr: "UDP DNS 服务器地址。",
timeout: "DNS 查询超时时间。",
},
tls: {
addr: "DNS over TLS 服务器地址。",
timeout: "DNS 查询超时时间。",
sni: "DNS over TLS 服务器的 SNI。",
insecure: "禁用 TLS 证书验证。",
},
https: {
addr: "DNS over HTTPS 服务器地址。",
timeout: "DNS 查询超时时间。",
sni: "DNS over TLS 服务器的 SNI。",
insecure: "禁用 TLS 证书验证。",
},
},
sniff: {
enable: "是否启用协议嗅探。",
timeout:
"嗅探超时时间。如果超过这个时间仍然无法确定协议/获取域名,将使用原地址发起连接。",
rewriteDomain:
"是否重写已经是域名的请求。如果启用,对于目标地址已经是域名的请求,仍会进行嗅探。",
tcpPorts: "TCP 端口列表。只有这些端口的 TCP 请求会被嗅探。",
udpPorts: "UDP 端口列表。只有这些端口的 UDP 请求会被嗅探。",
},
aclType: "ACL 类型",
acl: {
file: "ACL 文件的路径。",
inline: "内联 ACL 规则的列表。",
geoip:
"可选。取消注释以启用。GeoIP 数据库文件的路径。如果省略这个字段,Hysteria 会自动下载最新的数据库到工作目录。",
geosite:
"可选。取消注释以启用。GeoSite 数据库文件的路径。如果省略这个字段,Hysteria 会自动下载最新的数据库到工作目录。",
geoUpdateInterval:
"可选。GeoIP/GeoSite 数据库刷新的间隔。默认为 168 小时(1 周)。仅在 GeoIP/GeoSite 数据库是自动下载的情况下生效。",
},
outbounds: {
name: "出站规则的名称。在 ACL 中使用。",
type: "类型",
socks5: {
addr: "SOCKS5 代理地址。",
username: "可选。SOCKS5 代理用户名。",
password: "可选。SOCKS5 代理密码。",
},
http: {
url: "HTTP/HTTPS 代理 URL。(可以是 http:// 或 https:// 开头)",
insecure: "可选。禁用 TLS 证书验证。仅适用于 HTTPS 代理。",
},
direct: {
mode: "类型",
bindIPv4: "要绑定的本地 IPv4 地址。",
bindIPv6: "要绑定的本地 IPv6 地址。",
bindDevice: "要绑定的本地网卡。",
fastOpen: "启用 TCP 快速打开。",
},
},
trafficStats: {
listen: "监听地址。",
},
masquerade: {
type: "类型",
file: {
dir: "用于提供文件的目录。",
},
proxy: {
url: "要代理的网站的 URL。",
rewriteHost:
"是否重写 Host 头以匹配被代理的网站。如果目标网站通过 Host 识别请求的网站,这个选项是必须的。",
insecure: "禁用对代理网站的 TLS 验证。",
},
string: {
content: "要返回的字符串。",
headers: "可选。要返回的 HTTP 头列表。",
statusCode: "可选。要返回的 HTTP 状态码。默认为 200。",
},
listenHTTP: "HTTP (TCP) 监听地址。",
listenHTTPS: "HTTPS (TCP) 监听地址。",
forceHTTPS:
"是否强制使用 HTTPS。如果启用,HTTP 请求将被重定向到 HTTPS。",
},
},
},
};
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { useTagsViewStore } from "@/store/modules/tagsView";
const tagsViewStore = useTagsViewStore();
</script>
<template>
<section class="app-main">
<router-view v-slot="{ Component, route }">
<transition name="router-fade" mode="out-in">
<keep-alive :include="tagsViewStore.cachedViews">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</section>
</template>
<style lang="scss" scoped>
.app-main {
position: relative;
width: 100%;
/* 50= navbar 50 */
min-height: calc(100vh - 50px);
overflow: hidden;
background-color: var(--el-bg-color-page);
}
.fixed-header + .app-main {
padding-top: 50px;
}
.hasTagsView {
.app-main {
/* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 84px);
}
.fixed-header + .app-main {
min-height: 100vh;
padding-top: 84px;
}
}
</style>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "@/store/modules/app";
import { useTagsViewStore } from "@/store/modules/tagsView";
import { useAccountStore } from "@/store/modules/account";
const appStore = useAppStore();
const tagsViewStore = useTagsViewStore();
const accountStore = useAccountStore();
const route = useRoute();
const router = useRouter();
const { device } = storeToRefs(appStore); // desktop- || mobile-
/**
* 左侧菜单栏显示/隐藏
*/
function toggleSideBar() {
appStore.toggleSidebar(true);
}
/**
* vueUse 全屏
*/
const { isFullscreen, toggle } = useFullscreen();
/**
* 注销
*/
function logout() {
ElMessageBox.confirm("确定注销并退出系统吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
accountStore
.logout()
.then(() => {
tagsViewStore.delAllViews();
})
.then(() => {
router.push(`/login?redirect=${route.fullPath}`);
});
});
}
</script>
<template>
<!-- 顶部导航栏 -->
<div class="navbar">
<!-- 左侧面包屑 -->
<div class="flex">
<hamburger
:is-active="appStore.sidebar.opened"
@toggleClick="toggleSideBar"
/>
<breadcrumb />
</div>
<!-- 右侧导航设置 -->
<div class="flex">
<!-- 导航栏设置(窄屏隐藏)-->
<div class="setting-container" v-if="device !== 'mobile'">
<!--全屏 -->
<div class="setting-item" @click="toggle">
<svg-icon
:icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'"
/>
</div>
<!-- 布局大小 -->
<el-tooltip content="布局大小" effect="dark" placement="bottom">
<size-select class="setting-item" />
</el-tooltip>
<!--语言选择-->
<lang-select class="setting-item" />
</div>
<!-- 用户头像 -->
<el-dropdown trigger="click">
<div class="avatar-container">
<img src="/src/assets/logo.png" />
<i-ep-caret-bottom class="w-3 h-3" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="logout">
{{ $t("navbar.logout") }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<style lang="scss" scoped>
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
background-color: #fff;
box-shadow: 0 0 1px #0003;
.setting-container {
display: flex;
align-items: center;
.setting-item {
display: inline-block;
width: 30px;
height: 50px;
line-height: 50px;
color: #5a5e66;
text-align: center;
cursor: pointer;
&:hover {
background: rgb(249 250 251 / 100%);
}
}
}
.avatar-container {
display: flex;
align-items: center;
justify-items: center;
margin: 0 5px;
cursor: pointer;
img {
width: 40px;
height: 40px;
border-radius: 5px;
}
}
}
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { useSettingsStore } from "@/store/modules/settings";
import IconEpSunny from "~icons/ep/sunny";
import IconEpMoon from "~icons/ep/moon";
/**
* 暗黑模式
*/
const settingsStore = useSettingsStore();
const isDark = useDark();
const toggleDark = () => useToggle(isDark);
/**
* 切换布局
*/
function changeLayout(layout: string) {
settingsStore.changeSetting({ key: "layout", value: layout });
window.document.body.setAttribute("layout", settingsStore.layout);
}
//
const themeColors = ref<string[]>([
"#409EFF",
"#304156",
"#11a983",
"#13c2c2",
"#6959CD",
"#f5222d",
]);
/**
* 切换主题颜色
*/
function changeThemeColor(color: string) {
settingsStore.changeSetting({ key: "themeColor", value: color });
document.documentElement.style.setProperty(
"--el-color-primary",
settingsStore.themeColor
);
}
onMounted(() => {
window.document.body.setAttribute("layout", settingsStore.layout);
document.documentElement.style.setProperty(
"--el-color-primary",
settingsStore.themeColor
);
});
</script>
<template>
<div class="settings-container">
<h3 class="text-base font-bold">项目配置</h3>
<el-divider>主题</el-divider>
<div class="flex justify-center" @click.stop>
<el-switch
v-model="isDark"
@change="toggleDark"
inline-prompt
:active-icon="IconEpMoon"
:inactive-icon="IconEpSunny"
active-color="var(--el-fill-color-dark)"
inactive-color="var(--el-color-primary)"
/>
</div>
<el-divider>界面设置</el-divider>
<div class="py-[8px] flex justify-between">
<span class="text-xs">开启 Tags-View</span>
<el-switch v-model="settingsStore.tagsView" />
</div>
<div class="py-[8px] flex justify-between">
<span class="text-xs">固定 Header</span>
<el-switch v-model="settingsStore.fixedHeader" />
</div>
<div class="py-[8px] flex justify-between">
<span class="text-xs">侧边栏 Logo</span>
<el-switch v-model="settingsStore.sidebarLogo" />
</div>
<el-divider>主题颜色</el-divider>
<ul class="w-full space-x-2 flex justify-center py-2">
<li
class="inline-block w-[30px] h-[30px] cursor-pointer"
v-for="(color, index) in themeColors"
:key="index"
:style="{ background: color }"
@click="changeThemeColor(color)"
></li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.settings-container {
padding: 16px;
.layout {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
width: 100%;
height: 50px;
&-item {
position: relative;
width: 18%;
height: 45px;
overflow: hidden;
cursor: pointer;
background: #f0f2f5;
border-radius: 4px;
}
&-item.is-active {
border: 2px solid var(--el-color-primary);
}
&-mix div:nth-child(1) {
width: 100%;
height: 30%;
background: #1b2a47;
box-shadow: 0 0 1px #888;
}
&-mix div:nth-child(2) {
position: absolute;
bottom: 0;
left: 0;
width: 30%;
height: 70%;
background: #1b2a47;
box-shadow: 0 0 1px #888;
}
&-top div:nth-child(1) {
width: 100%;
height: 30%;
background: #1b2a47;
box-shadow: 0 0 1px #888;
}
&-left div:nth-child(1) {
width: 30%;
height: 100%;
background: #1b2a47;
}
&-left div:nth-child(2) {
position: absolute;
top: 0;
right: 0;
width: 70%;
height: 30%;
background: #fff;
box-shadow: 0 0 1px #888;
}
}
}
</style>
@@ -0,0 +1,37 @@
<script lang="ts" setup>
import { computed } from "vue";
import { isExternal } from "@/utils/index";
import { useRouter } from "vue-router";
import { useAppStore } from "@/store/modules/app";
const appStore = useAppStore();
const sidebar = computed(() => appStore.sidebar);
const device = computed(() => appStore.device);
const props = defineProps({
to: {
type: String,
required: true,
},
});
const router = useRouter();
function push() {
if (device.value === "mobile" && sidebar.value.opened == true) {
appStore.closeSideBar(false);
}
router.push(props.to).catch((err) => {
console.error(err);
});
}
</script>
<template>
<a v-if="isExternal(to)" :href="to" target="_blank" rel="noopener">
<slot />
</a>
<div v-else @click="push">
<slot />
</div>
</template>
@@ -0,0 +1,53 @@
<script lang="ts" setup>
import { useSettingsStore } from "@/store/modules/settings";
const settingsStore = useSettingsStore();
defineProps({
collapse: {
type: Boolean,
required: true,
},
});
const logo = ref(new URL(`../../../assets/logo.png`, import.meta.url).href);
</script>
<template>
<div class="w-full h-[50px] bg-gray-800 dark:bg-[var(--el-bg-color-overlay)]">
<transition name="sidebarLogoFade">
<router-link
v-if="collapse"
key="collapse"
class="h-full w-full flex items-center justify-center"
to="/"
>
<img v-if="settingsStore.sidebarLogo" :src="logo" class="w-5 h-5" />
<span v-else class="ml-3 text-white text-sm font-bold">HY2XS</span>
</router-link>
<router-link
v-else
key="expand"
class="h-full w-full flex items-center justify-center"
to="/"
>
<img v-if="settingsStore.sidebarLogo" :src="logo" class="w-5 h-5" />
<span class="ml-3 text-white text-sm font-bold">HY2XS</span>
</router-link>
</transition>
</div>
</template>
<style lang="scss" scoped>
// https://cn.vuejs.org/guide/built-ins/transition.html#the-transition-component
.sidebarLogoFade-enter-active {
transition: opacity 2s;
}
.sidebarLogoFade-leave-active,
.sidebarLogoFade-enter-from,
.sidebarLogoFade-leave-to {
opacity: 0;
}
</style>

Some files were not shown because too many files have changed in this diff Show More