5 Commits

Author SHA1 Message Date
comma
8d391d202f 🎨 cicd
Some checks failed
CI / Frontend Build (push) Has been cancelled
CI / Publish Docker Image (push) Has been cancelled
CI / Backend Check (push) Has been cancelled
2026-04-02 18:54:03 +08:00
comma
b3dd1549db 🎨 优化页面 2026-03-30 17:08:46 +08:00
2c6c0c0e2a 🎨 编辑 2025-12-13 07:35:49 +00:00
coward
1dc677f3a0 :bug:修复bug
All checks were successful
continuous-integration/drone/tag Build is passing
2025-03-04 09:01:29 +08:00
coward
2f9a4b5f6c :arrow_up:添加dontenv包,当环境变量不存在时读取env文件
All checks were successful
continuous-integration/drone/tag Build is passing
2025-03-04 08:48:06 +08:00
26 changed files with 2926 additions and 624 deletions

87
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,87 @@
name: CI
on:
push:
tags:
- "v*"
pull_request:
env:
GO_VERSION: "1.23.0"
NODE_VERSION: "18"
PNPM_VERSION: "8.6.10"
REGISTRY: code.mrx.ltd
IMAGE_NAME: code.mrx.ltd/pkg/wireguard-srv
jobs:
backend-check:
name: Backend Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Download Go modules
run: go mod download
- name: Run Go test
run: go test ./...
- name: Verify backend build
run: go build -o /tmp/wgui .
frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
run: pnpm build
docker-publish:
name: Publish Docker Image
if: startsWith(github.ref, 'refs/tags/v')
needs:
- backend-check
- frontend-build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login registry
run: echo "${{ secrets.DOCKER_PWD }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.DOCKER_USER }}" --password-stdin
- name: Build image
run: docker build -t "${{ env.IMAGE_NAME }}:${GITHUB_REF_NAME}" -t "${{ env.IMAGE_NAME }}:latest" .
- name: Push image
run: |
docker push "${{ env.IMAGE_NAME }}:${GITHUB_REF_NAME}"
docker push "${{ env.IMAGE_NAME }}:latest"

View File

@@ -1,4 +1,4 @@
# 打包前端 # 打包前端
FROM node:18-alpine AS build-front FROM node:18-alpine AS build-front
WORKDIR /front WORKDIR /front

1157
document/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

8
go.mod
View File

@@ -22,6 +22,7 @@ require (
github.com/go-resty/resty/v2 v2.15.3 github.com/go-resty/resty/v2 v2.15.3
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/mojocn/base64Captcha v1.3.6 github.com/mojocn/base64Captcha v1.3.6
@@ -43,14 +44,15 @@ require (
github.com/aliyun/aliyun-oss-go-sdk v2.2.5+incompatible // indirect github.com/aliyun/aliyun-oss-go-sdk v2.2.5+incompatible // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.12.9 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.2.2 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/caarlos0/env/v6 v6.10.1 // indirect github.com/caarlos0/env/v6 v6.10.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbletea v1.1.0 // indirect github.com/charmbracelet/bubbletea v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/ansi v0.2.3 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dennwc/varint v1.0.0 // indirect github.com/dennwc/varint v1.0.0 // indirect

24
go.sum
View File

@@ -169,11 +169,12 @@ github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx2
github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II=
github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
@@ -213,9 +214,8 @@ github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2u
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -776,6 +776,8 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8= github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
@@ -810,10 +812,8 @@ github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -1176,6 +1176,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -1185,8 +1186,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
@@ -2048,7 +2049,6 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@@ -184,6 +184,9 @@ func (setting) Import(c *gin.Context) {
response.R(c).FailedWithError(err) response.R(c).FailedWithError(err)
return return
} }
defer func() {
_ = fileBytes.Close()
}()
var data vo.Export var data vo.Export
if err := json.NewDecoder(fileBytes).Decode(&data); err != nil { if err := json.NewDecoder(fileBytes).Decode(&data); err != nil {

View File

@@ -140,8 +140,13 @@ func (UserApi) Delete(c *gin.Context) {
return return
} }
loginUser := GetCurrentLoginUser(c)
if c.IsAborted() {
return
}
// 是不是自己删除自己 // 是不是自己删除自己
if id == GetCurrentLoginUser(c).Id && c.IsAborted() { if id == loginUser.Id {
response.R(c).FailedWithError("非法操作") response.R(c).FailedWithError("非法操作")
return return
} }
@@ -178,8 +183,13 @@ func (UserApi) Status(c *gin.Context) {
return return
} }
loginUser := GetCurrentLoginUser(c)
if c.IsAborted() {
return
}
// 是不是自己删除自己 // 是不是自己删除自己
if id == GetCurrentLoginUser(c).Id && c.IsAborted() { if id == loginUser.Id {
response.R(c).FailedWithError("非法操作") response.R(c).FailedWithError("非法操作")
return return
} }

View File

@@ -20,7 +20,7 @@ func InitRouter() *gin.Engine {
// 开启IP 追踪 // 开启IP 追踪
r.ForwardedByClientIP = true r.ForwardedByClientIP = true
// 将请求打印至控制台 // 将请求打印至控制台
r.Use(gin.Logger()) r.Use(gin.Logger(), gin.Recovery())
if config.Config.File.Type == "local" { if config.Config.File.Type == "local" {
r.Static("/assets", config.Config.File.Path) r.Static("/assets", config.Config.File.Path)

View File

@@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/joho/godotenv"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.zx2c4.com/wireguard/wgctrl" "golang.zx2c4.com/wireguard/wgctrl"
@@ -27,6 +28,7 @@ import (
func Init() { func Init() {
initLogger() // 初始化日志 initLogger() // 初始化日志
initConfig() // 读取配置文件 initConfig() // 读取配置文件
initEnv() // 加载环境变量文件
initWireguard() // 初始化wireguard客户端 initWireguard() // 初始化wireguard客户端
initDatabase() // 初始化数据库 initDatabase() // 初始化数据库
initRedis() // 初始化redis initRedis() // 初始化redis
@@ -153,3 +155,11 @@ func initLogger() {
FileEnable: true, FileEnable: true,
}) })
} }
// initEnv
// @description: 初始化环境变量
func initEnv() {
if err := godotenv.Load(".env"); err != nil {
log.Errorf("加载.env文件失败: %v", err.Error())
}
}

View File

@@ -0,0 +1,72 @@
---
apply: 始终
---
# 前端开发全局强制准则 (V1.0 - Multi-Framework Pro)
## 1. 性能与资产安全 (Performance & Asset Safety)
* **加载优化 (Loading)**
* **强制路由懒加载**:所有页面组件必须使用动态 `import()` 导入。
* **按需引入**UI 框架Naive UI, Element Plus, AntD必须配置插件实现自动按需引入严禁全局注册。
* **渲染性能 (Rendering)**
* **长列表防御**:展示数据超过 50 条时,必须使用**虚拟列表 (Virtual List)** 或分页加载,禁止直接渲染大批量 DOM。
* **资产压缩**:禁止在页面加载超过 500KB 的原始图片,必须使用 WebP 格式或 CDN 缩略图参数。
* **内存释放 (Cleanup)**
* 定时器 (`setInterval`)、全局事件监听 (`window.addEventListener`)、WebSocket 必须在组件卸载钩子(`onUnmounted` / `useEffect cleanup`)中明确销毁。
## 2. 状态管理与数据流 (State & Data Flow)
* **状态分层原则**
* **局部优先**:仅在跨页面/跨多级组件共享时才使用 Pinia 或 Redux。
* **不可变性 (Immutability)**:严禁直接修改复杂对象的属性,必须使用解构赋值或 `set` 函数确保引用更新。
* **数据安全 (Safety)**
* **可选链操作**:渲染后端异步数据必须使用 `?.` 可选链,严禁因 `undefined` 导致页面白屏。
* **声明式校验**:表单提交前必须通过 Schema 校验,严禁依赖后端接口反馈作为唯一拦截手段。
## 3. 组件架构与规模控制 (Architecture & Scale)
* **单文件硬指标****单个组件文件代码禁止超过 1000 行**。
* **重构门禁**:超过 800 行时AI/开发者必须将业务逻辑抽离至 `Hooks` (Vue3 `useXXX` / React `useXXX`)。
* **层级规范**
* 目录嵌套严禁超过 4 层。
* **职责分离**:复杂的页面必须拆分为“逻辑容器组件”和“纯 UI 展示组件”。
* **通用逻辑封装**
* 业务无关的格式化、正则、计算函数必须存放在 `src/utils`
* 跨组件复用的逻辑必须封装为 `Hooks`
## 4. 命名与样式规范 (Naming & CSS)
* **精简命名**
* **组件命名**:采用 `PascalCase`(如 `UserCard.vue`),严禁超过 3 个单词。
* **方法前缀**:事件处理函数使用 `handle`(如 `handleSearch`),数据获取使用 `fetch``get`
* **样式隔离 (Scoped CSS)**
* 必须启用 `scoped``CSS Modules`,严禁定义可能污染全局的类名。
* **严禁内联样式**:禁止在 HTML 标签上写大量 `style` 属性。
* **变量化**:颜色、字号必须引用预定义的 CSS 变量,严禁在组件内散落十六进制颜色值。
## 5. 框架专项标准 (Framework Specifics)
### Vue3 (uniapp / Naive UI)
* **语法糖**:必须使用 `<script setup>` 组合式 API。
* **响应式选择**:单一基本类型用 `ref`,结构化对象用 `reactive`
* **uniapp 适配**:原生 API扫码、支付必须在 `src/utils/uni-tools.js` 中二次封装,禁止页面内直接调用原生接口。
### React
* **依赖闭包**`useEffect``useCallback` 必须明确声明所有依赖项,严禁由于闭包陷阱导致状态陈旧。
* **组件更新**:优先使用 `React.memo` 优化高频刷新的子组件。
---
## 🤖 智能体执行指令 (Agent Frontend Instructions)
1. **预审行数**AI 生成代码前统计当前行数。若生成后 > 1000 行AI 必须拒绝直接输出并主动提供逻辑拆分Hooks 提取)方案。
2. **安全性补全**
* 自动为后端返回的数据访问添加 `?.`
* 自动在卸载钩子中生成资源清理代码(如 `clearInterval`)。
3. **按需引入校验**AI 生成的 UI 组件代码必须采用按需导入模式,严禁 `import { ... } from 'ui-lib'` 全量导入。
4. **命名自检**:自动精简用户提供的冗长类名或方法名。
---
## 强制执行门禁 (Submission Gaterails)
* **ESLint/Prettier**:必须 100% 通过校验。
* **TS 约束**:严禁滥用 `any`。所有后端接口返回数据必须定义 `interface``type`
* **生产环境清理**:严禁残留 `console.log` 和测试注释。
* **一票否决**:违反任一强制规则(如白屏隐患、内存泄漏、样式全局污染),评审直接驳回。

View File

@@ -1,8 +1,10 @@
<template> <template>
<transition name="fade-slide" mode="out-in" appear> <transition name="fade-slide" mode="out-in" appear>
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15 dark:bg-hex-121212"> <section class="app-page-shell cus-scroll-y wh-full">
<slot /> <div class="app-page-body">
<AppFooter v-if="showFooter" mt-15 /> <slot />
<AppFooter v-if="showFooter" mt-15 />
</div>
<n-back-top :bottom="20" /> <n-back-top :bottom="20" />
</section> </section>
</transition> </transition>
@@ -16,3 +18,19 @@ defineProps({
}, },
}) })
</script> </script>
<style scoped lang="scss">
.app-page-shell {
position: relative;
padding: 16px;
background: #f5f7fa;
}
.app-page-body {
position: relative;
display: flex;
min-height: 100%;
flex-direction: column;
gap: 16px;
}
</style>

View File

@@ -18,19 +18,19 @@
</div> </div>
</AppPage> </AppPage>
<n-modal <n-modal
style="width: 25%" class="client-list-header-modal"
style="width: min(760px, calc(100vw - 48px))"
v-model:show="addModalShow" v-model:show="addModalShow"
preset="card" preset="card"
title="添加" title="添加"
header-style="margin-left: 40%" header-style="text-align: center"
> >
<n-form <n-form
ref="addModalFormRef" ref="addModalFormRef"
class="client-list-header-form"
:rules="addModalFormRules" :rules="addModalFormRules"
:model="addModalForm" :model="addModalForm"
label-placement="left" label-placement="top"
label-width="auto"
label-align="left"
require-mark-placement="right" require-mark-placement="right"
> >
<n-form-item label="名称" path="name"> <n-form-item label="名称" path="name">
@@ -39,7 +39,7 @@
<n-form-item label="邮箱" path="email"> <n-form-item label="邮箱" path="email">
<n-input v-model:value="addModalForm.email"/> <n-input v-model:value="addModalForm.email"/>
</n-form-item> </n-form-item>
<n-form-item label="IP" path="addModalForm.ipAllocation"> <n-form-item class="client-list-header-form__full" label="IP" path="addModalForm.ipAllocation">
<n-select <n-select
v-model:value="addModalForm.ipAllocation" v-model:value="addModalForm.ipAllocation"
filterable filterable
@@ -50,7 +50,7 @@
:show="false" :show="false"
/> />
</n-form-item> </n-form-item>
<n-form-item label="可访问IP" path="allowedIps"> <n-form-item class="client-list-header-form__full" label="可访问IP" path="allowedIps">
<n-select <n-select
v-model:value="addModalForm.allowedIps" v-model:value="addModalForm.allowedIps"
filterable filterable
@@ -61,7 +61,7 @@
:show="false" :show="false"
/> />
</n-form-item> </n-form-item>
<n-form-item label="可访问IP扩展" path="extraAllowedIps"> <n-form-item class="client-list-header-form__full" label="可访问IP扩展" path="extraAllowedIps">
<n-select <n-select
v-model:value="addModalForm.extraAllowedIps" v-model:value="addModalForm.extraAllowedIps"
filterable filterable
@@ -72,35 +72,39 @@
:show="false" :show="false"
/> />
</n-form-item> </n-form-item>
<n-form-item label="服务端DNS" path="useServerDns"> <n-form-item class="client-list-header-form__full" label="服务端DNS" path="useServerDns">
<n-radio value="1" :checked="addModalForm.useServerDns === 1" @change="addModalForm.useServerDns = 1"></n-radio> <n-radio-group class="client-list-header-form__radio" :value="addModalForm.useServerDns">
<n-radio value="0" :checked="addModalForm.useServerDns === 0" @change="addModalForm.useServerDns = 0"></n-radio> <n-radio :value="1" :checked="addModalForm.useServerDns === 1" @change="addModalForm.useServerDns = 1"></n-radio>
<n-radio :value="0" :checked="addModalForm.useServerDns === 0" @change="addModalForm.useServerDns = 0"></n-radio>
</n-radio-group>
</n-form-item> </n-form-item>
<n-form-item label="公钥" path="keys.publicKey"> <n-form-item class="client-list-header-form__full" label="公钥" path="keys.publicKey">
<n-input v-model:value="addModalForm.keys.publicKey"></n-input> <n-input v-model:value="addModalForm.keys.publicKey"></n-input>
</n-form-item> </n-form-item>
<n-form-item label="私钥" path="keys.privateKey"> <n-form-item class="client-list-header-form__full" label="私钥" path="keys.privateKey">
<n-input v-model:value="addModalForm.keys.privateKey"></n-input> <n-input v-model:value="addModalForm.keys.privateKey"></n-input>
</n-form-item> </n-form-item>
<n-form-item label="共享密钥" path="keys.presharedKey"> <n-form-item class="client-list-header-form__full" label="共享密钥" path="keys.presharedKey">
<n-input v-model:value="addModalForm.keys.presharedKey"></n-input> <n-input v-model:value="addModalForm.keys.presharedKey"></n-input>
</n-form-item> </n-form-item>
<n-form-item> <n-form-item class="client-list-header-form__full">
<n-button style="margin-left: 28%" size="small" type="info" @click="generateKeys">生成密钥对</n-button> <n-button size="small" secondary type="primary" @click="generateKeys">生成密钥对</n-button>
</n-form-item> </n-form-item>
<n-form-item label="状态" path="editModalForm.enabled"> <n-form-item class="client-list-header-form__full" label="状态" path="editModalForm.enabled">
<n-radio-group :value="addModalForm.enabled"> <n-radio-group class="client-list-header-form__radio" :value="addModalForm.enabled">
<n-radio :value="1" :checked="addModalForm.enabled === 1" @change="addModalForm.enabled = 1">启用</n-radio> <n-radio :value="1" :checked="addModalForm.enabled === 1" @change="addModalForm.enabled = 1">启用</n-radio>
<n-radio :value="0" :checked="addModalForm.enabled === 0" @change="addModalForm.enabled = 0">禁用</n-radio> <n-radio :value="0" :checked="addModalForm.enabled === 0" @change="addModalForm.enabled = 0">禁用</n-radio>
</n-radio-group> </n-radio-group>
</n-form-item> </n-form-item>
<n-form-item label="离线监听" path="offlineMonitoring"> <n-form-item class="client-list-header-form__full" label="离线监听" path="offlineMonitoring">
<n-radio-group :value="addModalForm.offlineMonitoring"> <n-radio-group class="client-list-header-form__radio" :value="addModalForm.offlineMonitoring">
<n-radio :value="1" :checked="addModalForm.offlineMonitoring === 1" @change="addModalForm.offlineMonitoring = 1">启用</n-radio> <n-radio :value="1" :checked="addModalForm.offlineMonitoring === 1" @change="addModalForm.offlineMonitoring = 1">启用</n-radio>
<n-radio :value="0" :checked="addModalForm.offlineMonitoring === 0" @change="addModalForm.offlineMonitoring = 0">禁用</n-radio> <n-radio :value="0" :checked="addModalForm.offlineMonitoring === 0" @change="addModalForm.offlineMonitoring = 0">禁用</n-radio>
</n-radio-group> </n-radio-group>
</n-form-item> </n-form-item>
<n-button type="info" style="margin-left: 40%" @click="confirmAddClient()">确认</n-button> <n-form-item class="client-list-header-form__full client-list-header-form__action">
<n-button type="primary" @click="confirmAddClient()">确认</n-button>
</n-form-item>
</n-form> </n-form>
</n-modal> </n-modal>
</template> </template>
@@ -226,4 +230,74 @@ function refreshList() {
} }
</script> </script>
<style scoped lang="scss">
.client-list-header-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
}
.client-list-header-form__full {
grid-column: 1 / -1;
}
.client-list-header-form__radio {
display: flex;
flex-wrap: wrap;
gap: 18px;
width: 100%;
padding: 10px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 12px;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
}
.client-list-header-form__action {
margin-top: 8px;
}
.client-list-header-form__action :deep(.n-button) {
min-width: 148px;
height: 42px;
border-radius: 12px;
}
.client-list-header-form :deep(.n-form-item-label) {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: #64748b;
}
.client-list-header-form :deep(.n-input),
.client-list-header-form :deep(.n-base-selection) {
width: 100%;
}
.client-list-header-form :deep(.n-input .n-input__input-el),
.client-list-header-form :deep(.n-base-selection .n-base-selection-label) {
min-height: 42px;
}
:deep(.client-list-header-modal .n-card) {
width: min(760px, calc(100vw - 48px));
border-radius: 20px;
overflow: hidden;
}
:deep(.client-list-header-modal .n-card-header) {
padding: 22px 24px 12px;
}
:deep(.client-list-header-modal .n-card__content) {
padding: 12px 24px 24px;
}
@media (max-width: 960px) {
.client-list-header-form {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,14 +1,14 @@
<template> <template>
<div flex items-center> <div class="header-left">
<MenuCollapse /> <MenuCollapse />
<BreadCrumb ml-15 hidden sm:block /> <BreadCrumb class="header-breadcrumb" hidden sm:block />
</div> </div>
<div ml-auto flex items-center v-if="loginUser.account === 'admin'"> <div v-if="loginUser.account === 'admin'" class="header-actions">
<Export/> <Export/>
<FullScreen /> <FullScreen />
<UserAvatar /> <UserAvatar />
</div> </div>
<div ml-auto flex items-center v-else> <div v-else class="header-actions">
<FullScreen /> <FullScreen />
<UserAvatar /> <UserAvatar />
</div> </div>
@@ -23,3 +23,24 @@ import Export from './components/Export.vue'
import { useUserStore } from '@/store' import { useUserStore } from '@/store'
const loginUser = useUserStore() const loginUser = useUserStore()
</script> </script>
<style scoped lang="scss">
.header-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.header-breadcrumb {
min-width: 0;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
padding-left: 16px;
}
</style>

View File

@@ -1,9 +1,12 @@
<template> <template>
<router-link h-60 f-c-c to="/"> <router-link class="side-logo" to="/">
<img src="@/assets/images/logo.png" height="42" /> <div class="side-logo__mark">
<h2 v-show="!appStore.collapsed" ml-10 max-w-140 flex-shrink-0 text-16 font-bold color-primary> <img src="@/assets/images/logo.png" height="42" />
{{ title }} </div>
</h2> <div v-show="!appStore.collapsed" class="side-logo__text">
<p class="side-logo__eyebrow">WireGuard Control</p>
<h2 class="side-logo__title">{{ title }}</h2>
</div>
</router-link> </router-link>
</template> </template>
@@ -13,3 +16,43 @@ const title = import.meta.env.VITE_TITLE
const appStore = useAppStore() const appStore = useAppStore()
</script> </script>
<style scoped lang="scss">
.side-logo {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
padding: 10px 8px;
border-radius: 14px;
color: #0f172a;
background: #eef4fb;
}
.side-logo__mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 12px;
background: #ffffff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.side-logo__eyebrow {
margin: 0;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #64748b;
}
.side-logo__title {
margin: 4px 0 0;
max-width: 110px;
font-size: 15px;
font-weight: 700;
line-height: 1.2;
color: #0f172a;
}
</style>

View File

@@ -102,19 +102,82 @@ function handleMenuSelect(key, item) {
} }
</script> </script>
<style lang="scss"> <style scoped lang="scss">
.side-menu {
flex: 1;
background: transparent !important;
:deep(.n-menu-item-content),
:deep(.n-submenu-children .n-menu-item-content),
:deep(.n-submenu-header) {
margin: 3px 0;
border-radius: 12px;
}
:deep(.n-menu-item-content) {
padding-left: 10px !important;
padding-right: 10px !important;
}
:deep(.n-menu-item-content::before),
:deep(.n-submenu-children .n-menu-item-content::before),
:deep(.n-submenu-header::before) {
left: 0 !important;
right: 0 !important;
border-radius: 12px;
}
:deep(.n-menu-item-content-header),
:deep(.n-menu-item-content__icon),
:deep(.n-menu-item-content__arrow),
:deep(.n-menu-item-content__icon svg),
:deep(.n-submenu-header),
:deep(.n-submenu-header .n-menu-item-content-header),
:deep(.n-submenu-header .n-menu-item-content__icon),
:deep(.n-submenu-header .n-menu-item-content__arrow),
:deep(.n-submenu-header .n-icon) {
color: #334155 !important;
}
:deep(.n-menu-item-content--selected),
:deep(.n-menu-item-content:hover),
:deep(.n-submenu-header:hover),
:deep(.n-submenu.n-submenu--child-active > .n-submenu-header) {
background: #eef4fb;
}
:deep(.n-menu-item-content--selected) {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.18);
}
:deep(.n-menu-item-content--selected .n-menu-item-content-header),
:deep(.n-menu-item-content--selected .n-menu-item-content__icon),
:deep(.n-menu-item-content--selected .n-menu-item-content__icon svg),
:deep(.n-submenu.n-submenu--child-active > .n-submenu-header),
:deep(.n-submenu.n-submenu--child-active > .n-submenu-header .n-menu-item-content-header),
:deep(.n-submenu.n-submenu--child-active > .n-submenu-header .n-menu-item-content__icon),
:deep(.n-submenu.n-submenu--child-active > .n-submenu-header .n-icon) {
color: #0f172a !important;
}
}
.side-menu:not(.n-menu--collapsed) { .side-menu:not(.n-menu--collapsed) {
.n-menu-item-content { :deep(.n-menu-item-content) {
&::before { &::before {
left: 5px; left: 0;
right: 5px; right: 0;
} }
&.n-menu-item-content--selected, &.n-menu-item-content--selected,
&:hover { &:hover {
&::before { &::before {
border-left: 4px solid var(--primary-color); border-left: 3px solid #3b82f6;
} }
} }
} }
:deep(.n-submenu-header) {
padding-left: 10px !important;
padding-right: 10px !important;
}
} }
</style> </style>

View File

@@ -4,6 +4,17 @@ import SideMenu from './components/SideMenu.vue'
</script> </script>
<template> <template>
<SideLogo /> <div class="sidebar-shell">
<SideMenu /> <SideLogo />
<SideMenu />
</div>
</template> </template>
<style scoped lang="scss">
.sidebar-shell {
display: flex;
height: 100%;
flex-direction: column;
padding: 10px 8px 16px;
}
</style>

View File

@@ -93,8 +93,8 @@ async function handleContextMenu(e, tagItem) {
} }
</script> </script>
<style> <style scoped>
.n-tag__close { :deep(.n-tag__close) {
box-sizing: content-box; box-sizing: content-box;
border-radius: 50%; border-radius: 50%;
font-size: 12px; font-size: 12px;

View File

@@ -1,7 +1,7 @@
<template> <template>
<n-layout has-sider wh-full> <n-layout class="app-shell" has-sider wh-full>
<n-layout-sider <n-layout-sider
bordered class="app-shell__sider"
collapse-mode="width" collapse-mode="width"
:collapsed-width="64" :collapsed-width="64"
:width="200" :width="200"
@@ -11,19 +11,14 @@
<SideBar /> <SideBar />
</n-layout-sider> </n-layout-sider>
<article flex-col flex-1 overflow-hidden> <article class="app-shell__main">
<header <header class="app-shell__header" :style="`height: ${header.height}px`">
border-b="1 solid #eee"
class="flex items-center bg-white px-15"
dark="bg-dark border-0"
:style="`height: ${header.height}px`"
>
<AppHeader /> <AppHeader />
</header> </header>
<section v-if="tags.visible" hidden border-b bc-eee sm:block dark:border-0> <section v-if="tags.visible" class="app-shell__tags" hidden sm:block>
<AppTags :style="{ height: `${tags.height}px` }" /> <AppTags :style="{ height: `${tags.height}px` }" />
</section> </section>
<section flex-1 overflow-hidden bg-hex-f5f6fb dark:bg-hex-101014> <section class="app-shell__content">
<AppMain /> <AppMain />
</section> </section>
</article> </article>
@@ -40,3 +35,53 @@ import { header, tags } from '~/settings'
const appStore = useAppStore() const appStore = useAppStore()
</script> </script>
<style scoped lang="scss">
.app-shell {
padding: 12px;
gap: 12px;
background: linear-gradient(180deg, #f3f6fa 0%, #ecf1f7 100%);
}
.app-shell__sider {
overflow: hidden;
border: 1px solid var(--app-shell-border);
border-radius: 18px;
background: #f8fafc;
box-shadow: var(--app-shell-shadow);
}
.app-shell__main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.app-shell__header,
.app-shell__tags {
display: flex;
align-items: center;
border: 1px solid var(--app-shell-border);
border-radius: 16px;
background: var(--app-shell-bg);
box-shadow: var(--app-shell-shadow);
}
.app-shell__header {
padding: 0 16px;
}
.app-shell__tags {
padding: 0 8px;
}
.app-shell__content {
min-height: 0;
flex: 1;
overflow: hidden;
border-radius: 18px;
}
</style>

View File

@@ -11,12 +11,12 @@ import App from './App.vue'
import { setupNaiveDiscreteApi } from './utils' import { setupNaiveDiscreteApi } from './utils'
import mitt from 'mitt' import mitt from 'mitt'
const EventMitt = mitt(); const eventBus = mitt()
async function setupApp() { async function setupApp() {
const app = createApp(App) const app = createApp(App)
setupStore(app) setupStore(app)
app.config.globalProperties.$bus = EventMitt; app.config.globalProperties.$bus = eventBus
await setupRouter(app) await setupRouter(app)
app.mount('#app') app.mount('#app')
setupNaiveDiscreteApi() setupNaiveDiscreteApi()

View File

@@ -1,3 +1,20 @@
body {
margin: 0;
color: #162033;
background: #eef3f8;
font-family: 'Avenir Next', 'PingFang SC', 'Helvetica Neue', sans-serif;
font-size: 14px;
line-height: 1.5;
}
:root {
--app-shell-bg: #ffffff;
--app-shell-border: rgba(148, 163, 184, 0.14);
--app-shell-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
--app-panel-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
--app-panel-radius: 16px;
}
html, html,
body { body {
width: 100%; width: 100%;
@@ -10,6 +27,15 @@ body {
height: 100%; height: 100%;
} }
a {
color: inherit;
}
.n-card {
border-color: var(--app-shell-border);
box-shadow: var(--app-panel-shadow);
}
/* transition fade-slide */ /* transition fade-slide */
.fade-slide-leave-active, .fade-slide-leave-active,
.fade-slide-enter-active { .fade-slide-enter-active {

View File

@@ -1,4 +1,5 @@
import * as NaiveUI from 'naive-ui' import { computed } from 'vue'
import { createDiscreteApi, darkTheme } from 'naive-ui'
import { isNullOrUndef } from '@/utils' import { isNullOrUndef } from '@/utils'
import { naiveThemeOverrides as themeOverrides } from '~/settings' import { naiveThemeOverrides as themeOverrides } from '~/settings'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
@@ -84,10 +85,10 @@ export function setupDialog(NDialog) {
export function setupNaiveDiscreteApi() { export function setupNaiveDiscreteApi() {
const appStore = useAppStore() const appStore = useAppStore()
const configProviderProps = computed(() => ({ const configProviderProps = computed(() => ({
theme: appStore.isDark ? NaiveUI.darkTheme : undefined, theme: appStore.isDark ? darkTheme : undefined,
themeOverrides, themeOverrides,
})) }))
const { message, dialog, notification, loadingBar } = NaiveUI.createDiscreteApi( const { message, dialog, notification, loadingBar } = createDiscreteApi(
['message', 'dialog', 'notification', 'loadingBar'], ['message', 'dialog', 'notification', 'loadingBar'],
{ configProviderProps } { configProviderProps }
) )

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
<template> <template>
<AppPage> <AppPage>
<n-card> <n-card class="setting-card form-surface" :bordered="false">
<n-tabs default-value="Server" justify-content="space-evenly" type="line" @update-value="tabChange"> <n-tabs class="setting-tabs" default-value="Server" justify-content="space-evenly" type="line" @update-value="tabChange">
<n-tab-pane name="Server" tab="服务端"> <n-tab-pane name="Server" tab="服务端">
<n-form <n-form
class="setting-form"
ref="serverFormRef" ref="serverFormRef"
:model="serverFormModel" :model="serverFormModel"
:rules="serverFormRules" :rules="serverFormRules"
label-placement="top"
> >
<n-form-item label="IP段" path="ipScope" :rule="{ <n-form-item label="IP段" path="ipScope" :rule="{
required: true, required: true,
@@ -44,34 +46,36 @@
trigger: ['change','blur'] trigger: ['change','blur']
} }
]"> ]">
<n-input-number :min="1120" :max="65535" v-model:value="serverFormModel.listenPort"/> <n-input-number :min="1120" :max="65535" v-model:value="serverFormModel.listenPort" />
</n-form-item> </n-form-item>
<n-form-item label="私钥" path="privateKey"> <n-form-item label="私钥" path="privateKey">
<n-input v-model:value="serverFormModel.privateKey"/> <n-input v-model:value="serverFormModel.privateKey" />
</n-form-item> </n-form-item>
<n-form-item label="公钥" path="publicKey"> <n-form-item label="公钥" path="publicKey">
<n-input v-model:value="serverFormModel.publicKey"/> <n-input v-model:value="serverFormModel.publicKey" />
</n-form-item> </n-form-item>
<n-form-item label="上行脚本" path="postUpScript"> <n-form-item label="上行脚本" path="postUpScript">
<n-input v-model:value="serverFormModel.postUpScript"/> <n-input v-model:value="serverFormModel.postUpScript" />
</n-form-item> </n-form-item>
<n-form-item label="下行脚本" path="postDownScript"> <n-form-item label="下行脚本" path="postDownScript">
<n-input v-model:value="serverFormModel.postDownScript"/> <n-input v-model:value="serverFormModel.postDownScript" />
</n-form-item> </n-form-item>
<n-form-item> <n-form-item class="setting-form__action">
<n-button type="info" @click="updateServerConf">确认</n-button> <n-button type="primary" @click="updateServerConf">保存服务端配置</n-button>
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="Global" tab="全局"> <n-tab-pane name="Global" tab="全局">
<n-form <n-form
class="setting-form"
ref="globalFormRef" ref="globalFormRef"
:model="globalFormModel" :model="globalFormModel"
:rules="globalFormRules" :rules="globalFormRules"
label-placement="top"
> >
<n-form-item label="公网IP" path="endpointAddress" class="pid"> <n-form-item label="公网IP" path="endpointAddress" class="setting-form__endpoint">
<n-input v-model:value="globalFormModel.endpointAddress"/> <n-input v-model:value="globalFormModel.endpointAddress" />
<n-button style="margin-top: 5px" size="small" type="warning" @click="getPublicAddr">获取地址</n-button> <n-button class="setting-form__inline-btn" size="small" secondary type="primary" @click="getPublicAddr">获取地址</n-button>
</n-form-item> </n-form-item>
<n-form-item label="DNS" path="dnsServer" :rule="{ <n-form-item label="DNS" path="dnsServer" :rule="{
required: true, required: true,
@@ -109,7 +113,7 @@
trigger: ['change','blur'] trigger: ['change','blur']
} }
]"> ]">
<n-input-number :min="100" :max="3000" v-model:value="globalFormModel.MTU"/> <n-input-number :min="100" :max="3000" v-model:value="globalFormModel.MTU" />
</n-form-item> </n-form-item>
<n-form-item label="persistentKeepalive" path="persistentKeepalive" :rule="[ <n-form-item label="persistentKeepalive" path="persistentKeepalive" :rule="[
{ {
@@ -131,24 +135,24 @@
trigger: ['change','blur'] trigger: ['change','blur']
} }
]"> ]">
<n-input-number :min="15" :max="300" v-model:value="globalFormModel.persistentKeepalive"/> <n-input-number :min="15" :max="300" v-model:value="globalFormModel.persistentKeepalive" />
</n-form-item> </n-form-item>
<n-form-item label="firewallMark" path="firewallMark"> <n-form-item label="firewallMark" path="firewallMark">
<n-input v-model:value="globalFormModel.firewallMark"/> <n-input v-model:value="globalFormModel.firewallMark" />
</n-form-item> </n-form-item>
<n-form-item label="table" path="table"> <n-form-item label="table" path="table">
<n-input v-model:value="globalFormModel.table"/> <n-input v-model:value="globalFormModel.table" />
</n-form-item> </n-form-item>
<n-form-item label="configPath" path="configFilePath"> <n-form-item label="configPath" path="configFilePath">
<n-input v-model:value="globalFormModel.configFilePath"/> <n-input v-model:value="globalFormModel.configFilePath" />
</n-form-item> </n-form-item>
<n-form-item> <n-form-item class="setting-form__action">
<n-button type="info" @click="updateGlobalConf">确认</n-button> <n-button type="primary" @click="updateGlobalConf">保存全局配置</n-button>
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="Other" tab="其他"> <n-tab-pane name="Other" tab="其他">
<n-button style="float:right;margin-bottom: 10px" size="small" type="info" @click="showAddModal = !showAddModal">添加</n-button> <n-button class="setting-other__add" size="small" type="primary" @click="showAddModal = !showAddModal">添加</n-button>
<n-data-table <n-data-table
:columns="tableColumns" :columns="tableColumns"
:data="taleData.data" :data="taleData.data"
@@ -160,15 +164,18 @@
:title="editFormModel.describe" :title="editFormModel.describe"
v-model:show="showEditModal" v-model:show="showEditModal"
preset="card" preset="card"
style="width: 30%" class="setting-modal"
style="width: min(560px, calc(100vw - 48px))"
> >
<n-form <n-form
class="setting-form setting-form--modal"
ref="editFormRef" ref="editFormRef"
:model="editFormModel" :model="editFormModel"
label-placement="top"
> >
<n-form-item v-for="(item,index) in editFormModel.data" :label="index"> <n-form-item v-for="(item,index) in editFormModel.data" :label="index">
<n-input v-if="typeof item === 'string'" v-model:value="editFormModel.data[index]"/> <n-input v-if="typeof item === 'string'" v-model:value="editFormModel.data[index]" />
<n-input-number v-else-if="typeof item === 'number'" v-model:value="editFormModel.data[index]"/> <n-input-number v-else-if="typeof item === 'number'" v-model:value="editFormModel.data[index]" />
<n-radio-group v-else-if="typeof item === 'boolean'" :value="editFormModel.data[index]"> <n-radio-group v-else-if="typeof item === 'boolean'" :value="editFormModel.data[index]">
<n-radio :value="true" :checked="editFormModel.data[index] === true" @change="editFormModel.data[index] = true"></n-radio> <n-radio :value="true" :checked="editFormModel.data[index] === true" @change="editFormModel.data[index] = true"></n-radio>
<n-radio :value="false" :checked="editFormModel.data[index] === false" @change="editFormModel.data[index] = false"></n-radio> <n-radio :value="false" :checked="editFormModel.data[index] === false" @change="editFormModel.data[index] = false"></n-radio>
@@ -177,8 +184,8 @@
<n-form-item label="配置描述"> <n-form-item label="配置描述">
<n-input v-model:value="editFormModel.describe" /> <n-input v-model:value="editFormModel.describe" />
</n-form-item> </n-form-item>
<n-form-item> <n-form-item class="setting-form__action">
<n-button type="info" @click="updateSetting">确认</n-button> <n-button type="primary" @click="updateSetting">保存</n-button>
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-modal> </n-modal>
@@ -186,20 +193,21 @@
title="添加" title="添加"
v-model:show="showAddModal" v-model:show="showAddModal"
preset="card" preset="card"
style="width: 30%" class="setting-modal"
style="width: min(560px, calc(100vw - 48px))"
> >
<n-form :model="addFormModel" ref="addFormRef"> <n-form class="setting-form setting-form--modal" :model="addFormModel" ref="addFormRef" label-placement="top">
<n-form-item label="Code"> <n-form-item label="Code">
<n-input v-model:value="addFormModel.code"></n-input> <n-input v-model:value="addFormModel.code" />
</n-form-item> </n-form-item>
<n-form-item label="选项"> <n-form-item label="选项">
<n-dynamic-input v-model:value="addFormModel.data" preset="pair" key-placeholder="" value-placeholder=""/> <n-dynamic-input v-model:value="addFormModel.data" preset="pair" key-placeholder="" value-placeholder="" />
</n-form-item> </n-form-item>
<n-form-item label="描述"> <n-form-item label="描述">
<n-input v-model:value="addFormModel.describe"></n-input> <n-input v-model:value="addFormModel.describe" />
</n-form-item> </n-form-item>
<n-form-item> <n-form-item class="setting-form__action">
<n-button type="info" @click="addSetting">确认</n-button> <n-button type="primary" @click="addSetting">添加配置</n-button>
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-modal> </n-modal>
@@ -395,12 +403,11 @@ async function getPublicAddr() {
} }
// 获取全部配置 // 获取全部配置
function allSetting() { async function allSetting() {
api.allSettings().then(res => { const res = await api.allSettings()
if (res.data.code === 200) { if (res.data.code === 200) {
taleData.value.data = res.data.data taleData.value.data = res.data.data
} }
})
} }
// tab切换事件 // tab切换事件
@@ -490,7 +497,7 @@ async function addSetting() {
} }
} }
$bus.on("refreshSetting",value => { const handleRefreshSetting = (value) => {
if (value) { if (value) {
if (tabCode.value === "" || tabCode.value === undefined) { if (tabCode.value === "" || tabCode.value === undefined) {
getServerConfig() getServerConfig()
@@ -505,11 +512,127 @@ $bus.on("refreshSetting",value => {
} }
} }
} }
}
$bus.on("refreshSetting", handleRefreshSetting)
onBeforeUnmount(() => {
$bus.off("refreshSetting", handleRefreshSetting)
}) })
getServerConfig() getServerConfig()
</script> </script>
<style lang="scss"> <style scoped lang="scss">
.pid .n-form-item-blank { .setting-card {
display: inline; border-radius: 14px;
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
} }
</style>
.setting-tabs {
:deep(.n-tabs-nav) {
margin-bottom: 18px;
}
}
.setting-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
}
.setting-form--modal {
grid-template-columns: 1fr;
}
.setting-form__wide {
grid-column: 1 / -1;
}
.setting-form :deep(.n-form-item) {
margin-bottom: 10px;
}
.setting-form :deep(.n-input),
.setting-form :deep(.n-input-number),
.setting-form :deep(.n-base-selection) {
width: 100%;
}
.setting-form :deep(.n-form-item-label) {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: #64748b;
}
.setting-form :deep(.n-input .n-input__input-el),
.setting-form :deep(.n-base-selection .n-base-selection-label),
.setting-form :deep(.n-input-number .n-input__input-el) {
min-height: 42px;
}
.setting-form__action {
grid-column: 1 / -1;
margin-top: 8px;
}
.setting-form__action :deep(.n-button) {
min-width: 148px;
height: 42px;
border-radius: 12px;
}
.setting-form__inline-btn {
min-width: 104px;
height: 42px;
padding: 0 16px;
border-radius: 12px;
}
.setting-other__add {
float: right;
margin-bottom: 12px;
}
.setting-form__endpoint :deep(.n-form-item-blank) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.setting-form__endpoint :deep(.n-input) {
flex: 1;
}
.setting-form :deep(.n-input-number .n-input-number-button) {
width: 34px;
}
.setting-form :deep(.n-input-number .n-input-number-button-icon) {
font-size: 14px;
}
:deep(.setting-modal) {
width: auto;
}
:deep(.setting-modal .n-card) {
width: min(560px, calc(100vw - 48px));
border-radius: 20px;
overflow: hidden;
}
:deep(.setting-modal .n-card-header) {
padding: 22px 24px 12px;
}
:deep(.setting-modal .n-card__content) {
padding: 12px 24px 24px;
}
@media (max-width: 960px) {
.setting-form {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -13,8 +13,10 @@
{{ $route.meta.title }} {{ $route.meta.title }}
</template> </template>
<template #header-extra> <template #header-extra>
<n-button v-if="useUserStore().isAdmin === 1" size="small" type="info" @click="addUser()">添加</n-button> <div class="user-header__actions">
<n-button style="margin-left: 5px" size="small" type="primary" @click="getUserList()">刷新</n-button> <n-button v-if="useUserStore().isAdmin === 1" size="small" type="info" @click="addUser()">添加</n-button>
<n-button size="small" type="primary" @click="getUserList()">刷新</n-button>
</div>
</template> </template>
<n-data-table <n-data-table
remote remote
@@ -33,33 +35,32 @@
:title="infoFormModel.nickname || '个人资料'" :title="infoFormModel.nickname || '个人资料'"
:bordered="false" :bordered="false"
size="large" size="large"
style="width: 400px" class="user-form-modal"
style="width: min(520px, calc(100vw - 48px))"
header-style="text-align: center" header-style="text-align: center"
> >
<n-form <n-form
ref="infoFormRef" ref="infoFormRef"
class="user-form"
:rules="infoFormRules" :rules="infoFormRules"
:model="infoFormModel" :model="infoFormModel"
label-placement="left" label-placement="top"
label-width="auto"
label-align="right"
require-mark-placement="left"
> >
<n-form-item label="账号" path="account"> <n-form-item label="账号" path="account">
<n-input v-if="infoFormModel.id !== ''" disabled v-model:value="infoFormModel.account"></n-input> <n-input v-if="infoFormModel.id !== ''" disabled v-model:value="infoFormModel.account" />
<n-input v-else v-model:value="infoFormModel.account"></n-input> <n-input v-else v-model:value="infoFormModel.account" />
</n-form-item> </n-form-item>
<n-form-item v-if="infoFormModel.id === ''" label="密码" path="password"> <n-form-item v-if="infoFormModel.id === ''" label="密码" path="password">
<n-input type="password" v-model:value="infoFormModel.password"></n-input> <n-input type="password" v-model:value="infoFormModel.password" />
</n-form-item> </n-form-item>
<n-form-item label="昵称" path="nickname"> <n-form-item label="昵称" path="nickname">
<n-input v-model:value="infoFormModel.nickname"></n-input> <n-input v-model:value="infoFormModel.nickname" />
</n-form-item> </n-form-item>
<n-form-item label="联系方式" path="contact"> <n-form-item label="联系方式" path="contact">
<n-input v-model:value="infoFormModel.contact"></n-input> <n-input v-model:value="infoFormModel.contact" />
</n-form-item> </n-form-item>
<n-form-item label="管理员"> <n-form-item class="user-form__full" label="管理员">
<n-space> <n-space class="user-form__radio">
<n-radio <n-radio
:checked="infoFormModel.isAdmin === 1" :checked="infoFormModel.isAdmin === 1"
value="1" value="1"
@@ -76,8 +77,8 @@
</n-radio> </n-radio>
</n-space> </n-space>
</n-form-item> </n-form-item>
<n-form-item label="状态"> <n-form-item class="user-form__full" label="状态">
<n-space> <n-space class="user-form__radio">
<n-radio <n-radio
:checked="infoFormModel.status === 1" :checked="infoFormModel.status === 1"
value="1" value="1"
@@ -94,7 +95,9 @@
</n-radio> </n-radio>
</n-space> </n-space>
</n-form-item> </n-form-item>
<n-button style="margin-left: 20%" type="primary" @click="SaveUser(infoFormModel)">确认</n-button> <n-form-item class="user-form__full user-form__action">
<n-button type="primary" @click="SaveUser(infoFormModel)">保存用户</n-button>
</n-form-item>
</n-form> </n-form>
</n-modal> </n-modal>
</template> </template>
@@ -403,12 +406,93 @@ function addUser() {
showInfoModel.value = true showInfoModel.value = true
} }
$bus.on('refreshUserInfo',value => { const handleRefreshUserInfo = (value) => {
if (value) { if (value) {
getUserList(); getUserList()
} }
}
$bus.on('refreshUserInfo', handleRefreshUserInfo)
onBeforeUnmount(() => {
$bus.off('refreshUserInfo', handleRefreshUserInfo)
}) })
getUserList() getUserList()
</script> </script>
<style></style> <style scoped lang="scss">
.user-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
}
.user-form__full {
grid-column: 1 / -1;
}
.user-header__actions {
display: flex;
gap: 8px;
}
.user-form__radio {
display: flex;
flex-wrap: wrap;
gap: 18px;
width: 100%;
padding: 10px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 12px;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
}
.user-form__action {
margin-top: 8px;
}
.user-form__action :deep(.n-button) {
min-width: 148px;
height: 42px;
border-radius: 12px;
}
.user-form :deep(.n-form-item-label) {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: #64748b;
}
.user-form :deep(.n-input) {
width: 100%;
}
.user-form :deep(.n-input .n-input__input-el) {
min-height: 42px;
}
:deep(.user-form-modal) {
width: auto;
}
:deep(.user-form-modal .n-card) {
width: min(520px, calc(100vw - 48px));
border-radius: 20px;
overflow: hidden;
}
:deep(.user-form-modal .n-card-header) {
padding: 22px 24px 12px;
}
:deep(.user-form-modal .n-card__content) {
padding: 12px 24px 24px;
}
@media (max-width: 640px) {
.user-form {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,46 +1,71 @@
<template> <template>
<AppPage :show-footer="true"> <AppPage :show-footer="true">
<div class="flex"> <section class="workbench-overview">
<n-card class="w-30%"> <div class="workbench-overview__title">
<div class="flex items-center"> <h1>概览</h1>
<n-avatar round :size="60" :src="userStore.avatar" /> <p>查看客户端连接和最近操作</p>
<div class="ml-20 flex-col"> </div>
<span class="text-20 opacity-80">Hello, {{ userStore.nickname }}</span> <div class="workbench-overview__stats">
<span class="mt-4 opacity-50">今日事今日毕</span> <span>在线客户端 {{ onlineCount }}</span>
</div> <span>客户端总数 {{ connectionCount }}</span>
<span>日志 {{ paginate.itemCount }}</span>
</div>
<div class="workbench-overview__user">
<n-avatar round :size="40" :src="userStore.avatar" />
<div>
<strong>{{ userStore.nickname }}</strong>
<p>{{ dailyPoetry.content || '莫向外求,但从心觅,行有不得,反求诸己。' }}</p>
</div> </div>
</div>
</section>
<p class="mt-40 text-14 opacity-60">{{ dailyPoetry.content || '莫向外求,但从心觅,行有不得,反求诸己。' }}</p> <section class="workbench-grid">
<p class="mt-32 text-right text-12 opacity-40"> {{ dailyPoetry.author || '佚名' }}</p> <n-card class="panel-card" :bordered="false">
</n-card> <template #header>
<n-card class="ml-12 w-70%"> <div class="panel-card__header">
<h3>最近操作日志</h3>
</div>
</template>
<n-data-table <n-data-table
class="dashboard-table"
remote remote
:columns="tableColumns" :columns="tableColumns"
:data="tableData.data" :data="tableData.data"
:pagination="paginate" :pagination="paginate"
/> />
</n-card> </n-card>
</div>
<n-card> <n-card class="panel-card" :bordered="false">
<n-data-table <template #header>
remote <div class="panel-card__header">
:columns="connectionsColumns" <h3>客户端连接状态</h3>
:data="connectionsData.data" </div>
:row-props="rowProps" </template>
/> <div
<n-dropdown class="connections-panel"
placement="bottom-start" @click="closeDropdown"
trigger="manual" @contextmenu.prevent="openDropdown"
size="small" >
:x="xRef" <n-data-table
:y="yRef" class="dashboard-table"
:options="rightMenuOpts" remote
@select="rowSelect" :columns="connectionsColumns"
@clickoutside="rowClick" :data="connectionsData.data"
:show="showDropdownRef" />
/> <n-dropdown
</n-card> placement="bottom-start"
trigger="manual"
size="small"
:x="xRef"
:y="yRef"
:options="rightMenuOpts"
@select="rowSelect"
@clickoutside="closeDropdown"
:show="showDropdownRef"
/>
</div>
</n-card>
</section>
</AppPage> </AppPage>
</template> </template>
@@ -49,8 +74,27 @@ import { useUserStore } from '@/store'
import api from '@/views/workbench/api' import api from '@/views/workbench/api'
import { debounce, renderIcon } from '@/utils' import { debounce, renderIcon } from '@/utils'
import { NTag } from 'naive-ui' import { NTag } from 'naive-ui'
const userStore = useUserStore() const userStore = useUserStore()
const tableData = ref({
data: []
})
const connectionsData = ref({
data: []
})
const dailyPoetry = ref({
author: '',
content: ''
})
const onlineCount = computed(
() => connectionsData.value.data.filter((item) => item.online === true).length
)
const connectionCount = computed(() => connectionsData.value.data.length)
// 表格表头 // 表格表头
const tableColumns = [ const tableColumns = [
{ {
@@ -96,6 +140,7 @@ const tableColumns = [
titleAlign: 'center' titleAlign: 'center'
}, },
] ]
// 链接信息列表 // 链接信息列表
const connectionsColumns = [ const connectionsColumns = [
{ {
@@ -130,15 +175,15 @@ const connectionsColumns = [
render: (row) => { render: (row) => {
switch (row.online) { switch (row.online) {
case true: case true:
return h(NTag,{ return h(NTag, {
type: 'info', type: 'info',
},{ }, {
default: () => '在线' default: () => '在线'
}) })
case false: case false:
return h(NTag,{ return h(NTag, {
type: 'warning', type: 'warning',
},{ }, {
default: () => '离线' default: () => '离线'
}) })
} }
@@ -164,77 +209,56 @@ const connectionsColumns = [
} }
] ]
// 链接列表邮件刷新菜单
const rightMenuOpts = [ const rightMenuOpts = [
{ {
label: () => h('span',{ style: { color: 'green' }}, '刷新'), label: () => h('span', { style: { color: 'green' } }, '刷新'),
key: "refresh", key: 'refresh',
icon: renderIcon('tabler:refresh',{ size: 14 }) icon: renderIcon('tabler:refresh', { size: 14 })
} }
] ]
// 右键菜单的设置 const showDropdownRef = ref(false)
const showDropdownRef = ref(false); const xRef = ref(0)
const xRef = ref(0); const yRef = ref(0)
const yRef = ref(0);
// 右键菜单的基本位置逻辑 function openDropdown(e) {
function rowProps(row) { showDropdownRef.value = false
return { nextTick().then(() => {
onContextmenu: (e) => { showDropdownRef.value = true
// $message.info(JSON.stringify(row, null, 2)); xRef.value = e.clientX
e.preventDefault(); yRef.value = e.clientY
showDropdownRef.value = false; })
nextTick().then(() => {
showDropdownRef.value = true;
xRef.value = e.clientX;
yRef.value = e.clientY;
});
}
};
} }
// 右键菜单的逻辑 function closeDropdown() {
function rowSelect(row) {
switch (row) {
case "refresh":
getClientConnections()
showDropdownRef.value = false
}
}
function rowClick() {
showDropdownRef.value = false showDropdownRef.value = false
} }
// 表格数据 function onWindowKeydown(e) {
const tableData = ref({ if (e.key === 'Escape') {
data: [] closeDropdown()
}) }
}
// 链接数据 function rowSelect(row) {
const connectionsData = ref({ switch (row) {
data: [] case 'refresh':
}) getClientConnections()
closeDropdown()
}
}
const dailyPoetry = ref({
author: '',
content: ''
})
// 页码控件
const paginate = reactive({ const paginate = reactive({
page: 1, page: 1,
pageSize: 2, pageSize: 2,
itemCount: 0, itemCount: 0,
pageCount: 0, pageCount: 0,
onChange: (page) => { onChange: (page) => {
paginate.page = page; paginate.page = page
getLogsList() getLogsList()
} }
}) })
// 获取操作日志列表
async function getLogsList() { async function getLogsList() {
try { try {
const res = await api.logsList({ const res = await api.logsList({
@@ -242,56 +266,153 @@ async function getLogsList() {
size: paginate.pageSize, size: paginate.pageSize,
}) })
if (res.data.code === 200) { if (res.data.code === 200) {
tableData.value.data = res.data.data.records; tableData.value.data = res.data.data.records
paginate.itemCount = res.data.data.total; paginate.itemCount = res.data.data.total
paginate.pageCount = res.data.data.totalPage; paginate.pageCount = res.data.data.totalPage
} }
}catch (error) { } catch (error) {
return error return error
} }
} }
// 每日诗词
const dailyPoe = debounce(() => { const dailyPoe = debounce(() => {
getDailyPoetry() getDailyPoetry()
},800) }, 800)
// 获取每日诗词 async function getDailyPoetry() {
function getDailyPoetry() {
try { try {
api.dailyPoetry().then(res => { const res = await api.dailyPoetry()
if (res.data.code === 200) { if (res.data.code === 200) {
dailyPoetry.value.author = res.data.data.author; dailyPoetry.value.author = res.data.data.author
dailyPoetry.value.content = res.data.data.content; dailyPoetry.value.content = res.data.data.content
} }
}) } catch (error) {
}catch (error) {
return error return error
} }
} }
const connectionList = debounce(() => {
getClientConnections()
},300)
// 获取客户端链接列表
async function getClientConnections() { async function getClientConnections() {
try { try {
const res = await api.clientConnections() const res = await api.clientConnections()
if (res.data.code === 200) { if (res.data.code === 200) {
connectionsData.value.data = res.data.data; connectionsData.value.data = res.data.data
} }
}catch (e) { } catch (e) {
return e return e
} }
} }
const initFunc = debounce(() => { const initFunc = debounce(() => {
getClientConnections() getClientConnections()
// dailyPoe() dailyPoe()
// connectionList() }, 500)
},500)
onMounted(() => {
window.addEventListener('keydown', onWindowKeydown)
window.addEventListener('blur', closeDropdown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onWindowKeydown)
window.removeEventListener('blur', closeDropdown)
})
getLogsList() getLogsList()
initFunc() initFunc()
</script> </script>
<style scoped lang="scss">
.workbench-overview {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 4px 2px;
}
.workbench-overview__title h1,
.panel-card__header h3 {
margin: 0;
font-size: 18px;
line-height: 1.4;
color: #0f172a;
}
.workbench-overview__title p {
margin: 4px 0 0;
font-size: 13px;
color: #64748b;
}
.workbench-overview__stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.workbench-overview__stats span {
padding: 6px 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 999px;
font-size: 13px;
color: #475569;
background: rgba(255, 255, 255, 0.9);
}
.workbench-overview__user {
display: flex;
align-items: flex-start;
gap: 10px;
max-width: 360px;
}
.workbench-overview__user strong {
display: block;
font-size: 14px;
color: #0f172a;
}
.workbench-overview__user p {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.6;
color: #64748b;
}
.workbench-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.panel-card {
border-radius: 14px;
background: #ffffff;
}
.panel-card__header {
display: flex;
align-items: center;
justify-content: space-between;
color: #0f172a;
}
.dashboard-table:deep(.n-data-table-wrapper) {
overflow: hidden;
border-radius: 10px;
}
.connections-panel {
position: relative;
}
@media (max-width: 960px) {
.workbench-overview {
flex-direction: column;
}
.workbench-overview__user {
max-width: none;
}
}
</style>

File diff suppressed because one or more lines are too long