Compare commits

...
3 Commits
51 changed files with 5948 additions and 568 deletions
+9 -1
View File
@@ -4,6 +4,7 @@ import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
@@ -116,10 +117,17 @@ func (s *DictionaryApi) FindSysDictionary(c *gin.Context) {
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data query request.SysDictionarySearch true "字典 name 或者 type"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "分页获取SysDictionary列表,返回包括列表,总数,页码,每页数量"
// @Router /sysDictionary/getSysDictionaryList [get]
func (s *DictionaryApi) GetSysDictionaryList(c *gin.Context) {
list, err := dictionaryService.GetSysDictionaryInfoList()
var dictionary request.SysDictionarySearch
err := c.ShouldBindQuery(&dictionary)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
list, err := dictionaryService.GetSysDictionaryInfoList(c, dictionary)
if err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败", c)
+215 -250
View File
@@ -1,210 +1,3 @@
# github.com/flipped-aurora/gin-vue-admin/server Global Configuration
# jwt configuration
jwt:
signing-key: qmPlus
expires-time: 7d
buffer-time: 1d
issuer: qmPlus
# zap logger configuration
zap:
level: info
format: console
prefix: "[github.com/flipped-aurora/gin-vue-admin/server]"
director: log
show-line: true
encode-level: LowercaseColorLevelEncoder
stacktrace-key: stacktrace
log-in-console: true
retention-day: -1
# redis configuration
redis:
#是否使用redis集群模式
useCluster: false
#使用集群模式addr和db默认无效
addr: 127.0.0.1:6379
password: ""
db: 0
clusterAddrs:
- "172.21.0.3:7000"
- "172.21.0.4:7001"
- "172.21.0.2:7002"
# redis-list configuration
redis-list:
- name: cache # 数据库的名称,注意: name 需要在 redis-list 中唯一
useCluster: false # 是否使用redis集群模式
addr: 127.0.0.1:6379 # 使用集群模式addr和db默认无效
password: ""
db: 0
clusterAddrs:
- "172.21.0.3:7000"
- "172.21.0.4:7001"
- "172.21.0.2:7002"
# mongo configuration
mongo:
coll: ''
options: ''
database: ''
username: ''
password: ''
auth-source: ''
min-pool-size: 0
max-pool-size: 100
socket-timeout-ms: 0
connect-timeout-ms: 0
is-zap: false
hosts:
- host: ''
port: ''
# email configuration
email:
to: xxx@qq.com
port: 465
from: xxx@163.com
host: smtp.163.com
is-ssl: true
secret: xxx
nickname: test
# system configuration
system:
env: local # 修改为public可以关闭路由日志输出
addr: 8888
db-type: mysql
oss-type: local # 控制oss选择走本地还是 七牛等其他仓 自行增加其他oss仓可以在 server/utils/upload/upload.go 中 NewOss函数配置
use-redis: false # 使用redis
use-mongo: false # 使用mongo
use-multipoint: false
# IP限制次数 一个小时15000次
iplimit-count: 15000
# IP限制一个小时
iplimit-time: 3600
# 路由全局前缀
router-prefix: ""
# 严格角色模式 打开后权限将会存在上下级关系
use-strict-auth: false
# captcha configuration
captcha:
key-long: 6
img-width: 240
img-height: 80
open-captcha: 0 # 0代表一直开启,大于0代表限制次数
open-captcha-timeout: 3600 # open-captcha大于0时才生效
# mysql connect configuration
# 未初始化之前请勿手动修改数据库信息!!!如果一定要手动初始化请看(https://gin-vue-admin.com/docs/first_master)
mysql:
path: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
max-idle-conns: 10
max-open-conns: 100
log-mode: ""
log-zap: false
# pgsql connect configuration
# 未初始化之前请勿手动修改数据库信息!!!如果一定要手动初始化请看(https://gin-vue-admin.com/docs/first_master)
pgsql:
path: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
max-idle-conns: 10
max-open-conns: 100
log-mode: ""
log-zap: false
oracle:
path: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
max-idle-conns: 10
max-open-conns: 100
log-mode: ""
log-zap: false
mssql:
path: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
max-idle-conns: 10
max-open-conns: 100
log-mode: ""
log-zap: false
sqlite:
path: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
max-idle-conns: 10
max-open-conns: 100
log-mode: ""
log-zap: false
db-list:
- disable: true # 是否禁用
type: "" # 数据库的类型,目前支持mysql、pgsql、mssql、oracle
alias-name: "" # 数据库的名称,注意: alias-name 需要在db-list中唯一
path: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
max-idle-conns: 10
max-open-conns: 100
log-mode: ""
log-zap: false
# local configuration
local:
path: uploads/file
store-path: uploads/file
# autocode configuration
autocode:
web: web/src
root: "" # root 自动适配项目根目录, 请不要手动配置,他会在项目加载的时候识别出根路径
server: server
module: 'github.com/flipped-aurora/gin-vue-admin/server'
ai-path: "" # AI服务路径
# qiniu configuration (请自行七牛申请对应的 公钥 私钥 bucket 和 域名地址)
qiniu:
zone: ZoneHuaDong
bucket: ""
img-path: ""
use-https: false
access-key: ""
secret-key: ""
use-cdn-domains: false
# minio oss configuration
minio:
endpoint: yourEndpoint
access-key-id: yourAccessKeyId
access-key-secret: yourAccessKeySecret
bucket-name: yourBucketName
use-ssl: false
base-path: ""
bucket-url: "http://host:9000/yourBucketName"
# aliyun oss configuration
aliyun-oss:
endpoint: yourEndpoint
access-key-id: yourAccessKeyId
@@ -212,29 +5,28 @@ aliyun-oss:
bucket-name: yourBucketName
bucket-url: yourBucketUrl
base-path: yourBasePath
# tencent cos configuration
tencent-cos:
bucket: xxxxx-10005608
region: ap-shanghai
secret-id: your-secret-id
secret-key: your-secret-key
base-url: https://gin.vue.admin
path-prefix: github.com/flipped-aurora/gin-vue-admin/server
# aws s3 configuration (minio compatible)
autocode:
web: web/src
root: /Users/panghu/Documents/gva/gin-vue-admin
server: server
module: github.com/flipped-aurora/gin-vue-admin/server
ai-path: ""
aws-s3:
bucket: xxxxx-10005608
region: ap-shanghai
endpoint: ""
s3-force-path-style: false
disable-ssl: false
secret-id: your-secret-id
secret-key: your-secret-key
base-url: https://gin.vue.admin
path-prefix: github.com/flipped-aurora/gin-vue-admin/server
# cloudflare r2 configuration
s3-force-path-style: false
disable-ssl: false
captcha:
key-long: 6
img-width: 240
img-height: 80
open-captcha: 0
open-captcha-timeout: 3600
cloudflare-r2:
bucket: xxxx0bucket
base-url: https://gin.vue.admin.com
@@ -242,42 +34,215 @@ cloudflare-r2:
account-id: xxx_account_id
access-key-id: xxx_key_id
secret-access-key: xxx_secret_key
# huawei obs configuration
cors:
mode: strict-whitelist
whitelist:
- allow-origin: example1.com
allow-methods: POST, GET
allow-headers: Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id
expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
allow-credentials: true
- allow-origin: example2.com
allow-methods: GET, POST
allow-headers: content-type
expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
allow-credentials: true
db-list:
- type: ""
alias-name: ""
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
disable: true
disk-list:
- mount-point: /
email:
to: xxx@qq.com
from: xxx@163.com
host: smtp.163.com
secret: xxx
nickname: test
port: 465
is-ssl: true
is-loginauth: false
excel:
dir: ./resource/excel/
hua-wei-obs:
path: you-path
bucket: you-bucket
endpoint: you-endpoint
access-key: you-access-key
secret-key: you-secret-key
# excel configuration
excel:
dir: ./resource/excel/
# disk usage configuration
disk-list:
- mount-point: "/"
# 跨域配置
# 需要配合 server/initialize/router.go -> `Router.Use(middleware.CorsByRules())` 使用
cors:
mode: strict-whitelist # 放行模式: allow-all, 放行全部; whitelist, 白名单模式, 来自白名单内域名的请求添加 cors 头; strict-whitelist 严格白名单模式, 白名单外的请求一律拒绝
whitelist:
- allow-origin: example1.com
allow-headers: Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id
allow-methods: POST, GET
expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
allow-credentials: true # 布尔值
- allow-origin: example2.com
allow-headers: content-type
allow-methods: GET, POST
expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
allow-credentials: true # 布尔值
jwt:
signing-key: 151a53c2-4dfa-42f2-aba3-0c3ad96535d9
expires-time: 7d
buffer-time: 1d
issuer: qmPlus
local:
path: uploads/file
store-path: uploads/file
mcp:
name: GVA_MCP
version: v1.0.0
sse_path: /sse
message_path: /message
url_prefix: ''
url_prefix: ""
minio:
endpoint: yourEndpoint
access-key-id: yourAccessKeyId
access-key-secret: yourAccessKeySecret
bucket-name: yourBucketName
use-ssl: false
base-path: ""
bucket-url: http://host:9000/yourBucketName
mongo:
coll: ""
options: ""
database: ""
username: ""
password: ""
auth-source: ""
min-pool-size: 0
max-pool-size: 100
socket-timeout-ms: 0
connect-timeout-ms: 0
is-zap: false
hosts:
- host: ""
port: ""
mssql:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
mysql:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
oracle:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
pgsql:
prefix: ""
port: "5432"
config: sslmode=disable TimeZone=Asia/Shanghai
db-name: gva
username: jutze
password: jutze123
path: 127.0.0.1
engine: ""
log-mode: error
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
qiniu:
zone: ZoneHuaDong
bucket: ""
img-path: ""
access-key: ""
secret-key: ""
use-https: false
use-cdn-domains: false
redis:
name: ""
addr: 127.0.0.1:6379
password: ""
db: 0
useCluster: false
clusterAddrs:
- 172.21.0.3:7000
- 172.21.0.4:7001
- 172.21.0.2:7002
redis-list:
- name: cache
addr: 127.0.0.1:6379
password: ""
db: 0
useCluster: false
clusterAddrs:
- 172.21.0.3:7000
- 172.21.0.4:7001
- 172.21.0.2:7002
sqlite:
prefix: ""
port: ""
config: ""
db-name: ""
username: ""
password: ""
path: ""
engine: ""
log-mode: ""
max-idle-conns: 10
max-open-conns: 100
singular: false
log-zap: false
system:
db-type: pgsql
oss-type: local
router-prefix: ""
addr: 8888
iplimit-count: 15000
iplimit-time: 3600
use-multipoint: false
use-redis: false
use-mongo: false
use-strict-auth: false
tencent-cos:
bucket: xxxxx-10005608
region: ap-shanghai
secret-id: your-secret-id
secret-key: your-secret-key
base-url: https://gin.vue.admin
path-prefix: github.com/flipped-aurora/gin-vue-admin/server
zap:
level: info
prefix: '[github.com/flipped-aurora/gin-vue-admin/server]'
format: console
director: log
encode-level: LowercaseColorLevelEncoder
stacktrace-key: stacktrace
show-line: true
log-in-console: true
retention-day: -1
+12
View File
@@ -73,9 +73,11 @@ require (
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/doquangtan/socketio/v4 v4.1.6 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/fasthttp/websocket v1.5.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gammazero/toposort v0.1.1 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
@@ -89,12 +91,15 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.24.0 // indirect
github.com/gofiber/fiber/v2 v2.52.9 // indirect
github.com/gofiber/websocket/v2 v2.2.1 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -115,7 +120,9 @@ require (
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microsoft/go-mssqldb v1.8.0 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minlz v1.0.0 // indirect
@@ -135,9 +142,11 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sijms/go-ora/v2 v2.7.17 // indirect
github.com/sorairolake/lzip-go v0.3.5 // indirect
@@ -153,6 +162,9 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
+26
View File
@@ -102,6 +102,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/doquangtan/socketio/v4 v4.1.6 h1:dpcO8IsQxNrvCJ7kNADfXMAmfomO9kTidKExboxtwpM=
github.com/doquangtan/socketio/v4 v4.1.6/go.mod h1:p43iXxVgwzOfdFg+TsC0bYXUHycwh6oYKwe+hkuDJu4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
@@ -113,6 +115,8 @@ github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
@@ -166,6 +170,10 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
@@ -230,6 +238,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -317,8 +327,13 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4=
github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
@@ -393,6 +408,8 @@ github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -407,6 +424,8 @@ github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsF
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
@@ -473,6 +492,12 @@ github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
@@ -639,6 +664,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+2
View File
@@ -2,6 +2,7 @@ package initialize
import (
"github.com/flipped-aurora/gin-vue-admin/server/plugin/announcement"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice"
"github.com/flipped-aurora/gin-vue-admin/server/utils/plugin/v2"
"github.com/gin-gonic/gin"
)
@@ -13,4 +14,5 @@ func PluginInitV2(group *gin.Engine, plugins ...plugin.Plugin) {
}
func bizPluginV2(engine *gin.Engine) {
PluginInitV2(engine, announcement.Plugin)
PluginInitV2(engine, notice.Plugin)
}
+2 -3
View File
@@ -1,13 +1,12 @@
package middleware
import (
"strconv"
"strings"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/gin-gonic/gin"
"strconv"
"strings"
)
// CasbinHandler 拦截器
@@ -0,0 +1,5 @@
package request
type SysDictionarySearch struct {
Name string `json:"name" form:"name" gorm:"column:name;comment:字典名(中)"` // 字典名(中)
}
+8
View File
@@ -0,0 +1,8 @@
package api
type ApiGroup struct {
NotificationApi
OnlineUserApi
}
var ApiGroupApp = new(ApiGroup)
@@ -0,0 +1,333 @@
package api
import (
"strconv"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/request"
noticeService "github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/service"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/gin-gonic/gin"
)
type NotificationApi struct{}
var notificationService = noticeService.ServiceGroupApp.NotificationService
// CreateNotification 创建通知
// @Tags NoticeCenter
// @Summary 创建一个新的通知
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.CreateNotificationRequest true "通知的标题、内容、类型等信息"
// @Success 200 {object} response.Response{msg=string} "创建成功"
// @Router /notice/createNotification [post]
func (a *NotificationApi) CreateNotification(c *gin.Context) {
var req request.CreateNotificationRequest
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取当前用户ID
userId := utils.GetUserID(c)
notification, err := notificationService.CreateNotification(req, userId)
if err != nil {
global.GVA_LOG.Error("创建通知失败!" + err.Error())
response.FailWithMessage("创建通知失败", c)
return
}
response.OkWithDetailed(notification, "创建成功", c)
}
// UpdateNotification 更新通知
// @Tags NoticeCenter
// @Summary 更新通知信息
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.UpdateNotificationRequest true "通知的更新信息"
// @Success 200 {object} response.Response{msg=string} "更新成功"
// @Router /notice/updateNotification [put]
func (a *NotificationApi) UpdateNotification(c *gin.Context) {
var req request.UpdateNotificationRequest
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
err = notificationService.UpdateNotification(req)
if err != nil {
global.GVA_LOG.Error("更新通知失败!" + err.Error())
response.FailWithMessage("更新通知失败", c)
return
}
response.OkWithMessage("更新成功", c)
}
// DeleteNotification 删除通知
// @Tags NoticeCenter
// @Summary 删除通知
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "通知ID"
// @Success 200 {object} response.Response{msg=string} "删除成功"
// @Router /notice/deleteNotification/{id} [delete]
func (a *NotificationApi) DeleteNotification(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的通知ID", c)
return
}
err = notificationService.DeleteNotification(uint(id))
if err != nil {
global.GVA_LOG.Error("删除通知失败!" + err.Error())
response.FailWithMessage("删除通知失败", c)
return
}
response.OkWithMessage("删除成功", c)
}
// GetNotificationList 获取通知列表
// @Tags NoticeCenter
// @Summary 获取通知列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.NotificationSearch true "分页和搜索条件"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /notice/getNotificationList [post]
func (a *NotificationApi) GetNotificationList(c *gin.Context) {
var req request.NotificationSearch
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
list, total, err := notificationService.GetNotificationList(req)
if err != nil {
global.GVA_LOG.Error("获取通知列表失败!" + err.Error())
response.FailWithMessage("获取通知列表失败", c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, "获取成功", c)
}
// GetNotificationById 根据ID获取通知
// @Tags NoticeCenter
// @Summary 根据ID获取通知详情
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "通知ID"
// @Success 200 {object} response.Response{data=model.Notification,msg=string} "获取成功"
// @Router /notice/getNotification/{id} [get]
func (a *NotificationApi) GetNotificationById(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的通知ID", c)
return
}
notification, err := notificationService.GetNotificationById(uint(id))
if err != nil {
global.GVA_LOG.Error("获取通知失败!" + err.Error())
response.FailWithMessage("获取通知失败", c)
return
}
response.OkWithDetailed(notification, "获取成功", c)
}
// PublishNotification 发布通知
// @Tags NoticeCenter
// @Summary 发布通知
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param id path int true "通知ID"
// @Success 200 {object} response.Response{msg=string} "发布成功"
// @Router /notice/publishNotification/{id} [post]
func (a *NotificationApi) PublishNotification(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的通知ID", c)
return
}
err = notificationService.PublishNotification(uint(id))
if err != nil {
global.GVA_LOG.Error("发布通知失败!" + err.Error())
response.FailWithMessage("发布通知失败", c)
return
}
response.OkWithMessage("发布成功", c)
}
// SendNotification 发送通知
// @Tags NoticeCenter
// @Summary 发送通知给指定用户或角色
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.SendNotificationRequest true "发送通知的请求参数"
// @Success 200 {object} response.Response{data=response.SendNotificationResponse,msg=string} "发送成功"
// @Router /notice/sendNotification [post]
func (a *NotificationApi) SendNotification(c *gin.Context) {
var req request.SendNotificationRequest
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取当前用户ID
userId := utils.GetUserID(c)
result, err := notificationService.SendNotification(req, userId)
if err != nil {
global.GVA_LOG.Error("发送通知失败!" + err.Error())
response.FailWithMessage("发送通知失败", c)
return
}
response.OkWithDetailed(result, "发送成功", c)
}
// GetUserNotifications 获取用户通知列表
// @Tags NoticeCenter
// @Summary 获取当前用户的通知列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.UserNotificationSearch true "分页和搜索条件"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /notice/getUserNotifications [post]
func (a *NotificationApi) GetUserNotifications(c *gin.Context) {
var req request.UserNotificationSearch
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取当前用户ID
userId := utils.GetUserID(c)
list, total, err := notificationService.GetUserNotifications(userId, req)
if err != nil {
global.GVA_LOG.Error("获取用户通知列表失败!" + err.Error())
response.FailWithMessage("获取用户通知列表失败", c)
return
}
response.OkWithDetailed(response.PageResult{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, "获取成功", c)
}
// MarkNotificationAsRead 标记通知为已读
// @Tags NoticeCenter
// @Summary 标记通知为已读
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.MarkReadRequest true "标记已读的请求参数"
// @Success 200 {object} response.Response{msg=string} "标记成功"
// @Router /notice/markAsRead [post]
func (a *NotificationApi) MarkNotificationAsRead(c *gin.Context) {
var req request.MarkReadRequest
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
// 获取当前用户ID
userId := utils.GetUserID(c)
err = notificationService.MarkNotificationsAsRead(userId, req.NotificationIds)
if err != nil {
global.GVA_LOG.Error("标记通知已读失败!" + err.Error())
response.FailWithMessage("标记通知已读失败", c)
return
}
response.OkWithMessage("标记成功", c)
}
// GetNotificationStats 获取通知统计信息
// @Tags NoticeCenter
// @Summary 获取通知统计信息
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Success 200 {object} response.Response{data=response.NotificationStatsResponse,msg=string} "获取成功"
// @Router /notice/getNotificationStats [get]
func (a *NotificationApi) GetNotificationStats(c *gin.Context) {
// 获取当前用户ID
userId := utils.GetUserID(c)
stats, err := notificationService.GetNotificationStats(userId)
if err != nil {
global.GVA_LOG.Error("获取通知统计失败!" + err.Error())
response.FailWithMessage("获取通知统计失败", c)
return
}
response.OkWithDetailed(stats, "获取成功", c)
}
// DeleteUserNotification 删除用户通知记录
// @Tags NoticeCenter
// @Summary 删除用户通知记录,如果通知只针对单个用户则删除通知本身
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param notificationId path int true "通知ID"
// @Success 200 {object} response.Response{msg=string} "删除成功"
// @Router /notice/deleteUserNotification/{notificationId} [delete]
func (a *NotificationApi) DeleteUserNotification(c *gin.Context) {
notificationIdStr := c.Param("notificationId")
notificationId, err := strconv.ParseUint(notificationIdStr, 10, 32)
if err != nil {
response.FailWithMessage("通知ID格式错误", c)
return
}
// 获取当前用户ID
userId := utils.GetUserID(c)
err = notificationService.DeleteUserNotification(userId, uint(notificationId))
if err != nil {
global.GVA_LOG.Error("删除用户通知失败!" + err.Error())
response.FailWithMessage(err.Error(), c)
return
}
response.OkWithMessage("删除成功", c)
}
+234
View File
@@ -0,0 +1,234 @@
package api
import (
"strconv"
"time"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/request"
noticeResponse "github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/response"
noticeService "github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/service"
"github.com/gin-gonic/gin"
)
type OnlineUserApi struct{}
var onlineUserService = noticeService.ServiceGroupApp.OnlineUserService
// GetOnlineUsers 获取在线用户列表
// @Tags NoticeCenter
// @Summary 获取在线用户列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Success 200 {object} response.Response{data=[]response.OnlineUserResponse,msg=string} "获取成功"
// @Router /notice/onlineUser/getOnlineUsers [get]
func (a *OnlineUserApi) GetOnlineUsers(c *gin.Context) {
onlineUsers, err := onlineUserService.GetOnlineUsers()
if err != nil {
global.GVA_LOG.Error("获取在线用户列表失败!" + err.Error())
response.FailWithMessage("获取在线用户列表失败", c)
return
}
response.OkWithDetailed(onlineUsers, "获取成功", c)
}
// GetOnlineUsersWithPagination 获取在线用户列表(分页)
// @Tags NoticeCenter
// @Summary 获取在线用户列表(支持分页和搜索)
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body request.OnlineUserSearch true "分页参数"
// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功"
// @Router /notice/onlineUser/getOnlineUsers [post]
func (a *OnlineUserApi) GetOnlineUsersWithPagination(c *gin.Context) {
var req request.OnlineUserSearch
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
onlineUsers, total, err := onlineUserService.GetOnlineUsersWithPagination(req)
if err != nil {
global.GVA_LOG.Error("获取在线用户列表失败!" + err.Error())
response.FailWithMessage("获取在线用户列表失败", c)
return
}
response.OkWithDetailed(response.PageResult{
List: onlineUsers,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, "获取成功", c)
}
// GetOnlineUsersByRole 根据角色获取在线用户
// @Tags NoticeCenter
// @Summary 根据角色获取在线用户列表
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param roleId path int true "角色ID"
// @Success 200 {object} response.Response{data=[]response.OnlineUserResponse,msg=string} "获取成功"
// @Router /notice/getOnlineUsersByRole/{roleId} [get]
func (a *OnlineUserApi) GetOnlineUsersByRole(c *gin.Context) {
roleIdStr := c.Param("roleId")
roleId, err := strconv.ParseUint(roleIdStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的角色ID", c)
return
}
// 根据角色获取用户ID列表
userIds, err := onlineUserService.GetUsersByRoleIds([]uint{uint(roleId)})
if err != nil {
global.GVA_LOG.Error("获取角色用户失败!" + err.Error())
response.FailWithMessage("获取角色用户失败", c)
return
}
// 获取所有在线用户
allOnlineUsers, err := onlineUserService.GetOnlineUsers()
if err != nil {
global.GVA_LOG.Error("获取在线用户失败!" + err.Error())
response.FailWithMessage("获取在线用户失败", c)
return
}
// 过滤出指定角色的在线用户
var onlineUsers []noticeResponse.OnlineUserResponse
userIdMap := make(map[uint]bool)
for _, id := range userIds {
userIdMap[id] = true
}
for _, user := range allOnlineUsers {
if userIdMap[user.UserId] {
onlineUsers = append(onlineUsers, user)
}
}
if err != nil {
global.GVA_LOG.Error("根据角色获取在线用户失败!" + err.Error())
response.FailWithMessage("根据角色获取在线用户失败", c)
return
}
response.OkWithDetailed(onlineUsers, "获取成功", c)
}
// CheckUserOnline 检查用户是否在线
// @Tags NoticeCenter
// @Summary 检查指定用户是否在线
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param userId path int true "用户ID"
// @Success 200 {object} response.Response{data=bool,msg=string} "检查成功"
// @Router /notice/checkUserOnline/{userId} [get]
func (a *OnlineUserApi) CheckUserOnline(c *gin.Context) {
userIdStr := c.Param("userId")
userId, err := strconv.ParseUint(userIdStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的用户ID", c)
return
}
isOnline := onlineUserService.IsUserOnline(uint(userId))
response.OkWithDetailed(isOnline, "检查成功", c)
}
// GetOnlineUserCount 获取在线用户数量
// @Tags NoticeCenter
// @Summary 获取当前在线用户数量
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Success 200 {object} response.Response{data=int64,msg=string} "获取成功"
// @Router /notice/getOnlineUserCount [get]
func (a *OnlineUserApi) GetOnlineUserCount(c *gin.Context) {
count, err := onlineUserService.GetOnlineUserCount()
if err != nil {
global.GVA_LOG.Error("获取在线用户数量失败!" + err.Error())
response.FailWithMessage("获取在线用户数量失败", c)
return
}
response.OkWithDetailed(count, "获取成功", c)
}
// GetOnlineUserStats 获取在线用户统计数据
// @Tags NoticeCenter
// @Summary 获取在线用户统计数据(总在线数、今日登录、峰值在线、平均在线)
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Success 200 {object} response.Response{data=response.OnlineUserStatsResponse,msg=string} "获取成功"
// @Router /notice/onlineUser/getOnlineUserStats [get]
func (a *OnlineUserApi) GetOnlineUserStats(c *gin.Context) {
stats, err := onlineUserService.GetOnlineUserStats()
if err != nil {
global.GVA_LOG.Error("获取在线用户统计数据失败!" + err.Error())
response.FailWithMessage("获取在线用户统计数据失败", c)
return
}
response.OkWithDetailed(stats, "获取成功", c)
}
// RemoveOnlineUser 移除在线用户
// @Tags NoticeCenter
// @Summary 移除指定的在线用户
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param userId path int true "用户ID"
// @Success 200 {object} response.Response{msg=string} "移除成功"
// @Router /notice/removeOnlineUser/{userId} [delete]
func (a *OnlineUserApi) RemoveOnlineUser(c *gin.Context) {
userIdStr := c.Param("userId")
userId, err := strconv.ParseUint(userIdStr, 10, 32)
if err != nil {
response.FailWithMessage("无效的用户ID", c)
return
}
//// 检查权限:只有管理员或用户本人可以移除
//currentUserId := utils.GetUserID(c)
//if currentUserId != uint(userId) {
// // 这里可以添加管理员权限检查
// // 暂时允许所有用户操作,实际项目中应该添加权限验证
//}
err = onlineUserService.RemoveOnlineUser(uint(userId))
if err != nil {
global.GVA_LOG.Error("移除在线用户失败!" + err.Error())
response.FailWithMessage("移除在线用户失败", c)
return
}
response.OkWithMessage("移除成功", c)
}
// CleanOfflineUsers 清理离线用户
// @Tags NoticeCenter
// @Summary 清理长时间未活跃的离线用户
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Success 200 {object} response.Response{msg=string} "清理成功"
// @Router /notice/cleanOfflineUsers [post]
func (a *OnlineUserApi) CleanOfflineUsers(c *gin.Context) {
// 清理30分钟未活跃的用户
err := onlineUserService.CleanOfflineUsers(30 * time.Minute)
if err != nil {
global.GVA_LOG.Error("清理离线用户失败!" + err.Error())
response.FailWithMessage("清理离线用户失败", c)
return
}
response.OkWithMessage("清理成功", c)
}
+4
View File
@@ -0,0 +1,4 @@
package config
type Config struct {
}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"gorm.io/gen"
"path/filepath"
)
//go:generate go mod tidy
//go:generate go mod download
//go:generate go run gen.go
func main() {
g := gen.NewGenerator(gen.Config{
OutPath: filepath.Join("..", "..", "..", "notice", "blender", "model", "dao"),
Mode: gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface,
})
g.ApplyBasic()
g.Execute()
}
+137
View File
@@ -0,0 +1,137 @@
package initialize
import (
"context"
model "github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/plugin-tool/utils"
)
func Api(ctx context.Context) {
entities := []model.SysApi{
// 通知管理相关API
{
Path: "/notice/notification/createNotification",
Description: "创建通知",
ApiGroup: "通知管理",
Method: "POST",
},
{
Path: "/notice/notification/updateNotification",
Description: "更新通知",
ApiGroup: "通知管理",
Method: "PUT",
},
{
Path: "/notice/notification/deleteNotification",
Description: "删除通知",
ApiGroup: "通知管理",
Method: "DELETE",
},
{
Path: "/notice/notification/publishNotification/:id",
Description: "发布通知",
ApiGroup: "通知管理",
Method: "POST",
},
{
Path: "/notice/notification/sendNotification",
Description: "发送通知",
ApiGroup: "通知管理",
Method: "POST",
},
{
Path: "/notice/notification/getNotificationList",
Description: "获取通知列表",
ApiGroup: "通知管理",
Method: "POST",
},
{
Path: "/notice/notification/getNotificationById/:id",
Description: "根据ID获取通知",
ApiGroup: "通知管理",
Method: "GET",
},
{
Path: "/notice/notification/getUserNotifications",
Description: "获取用户通知列表",
ApiGroup: "通知管理",
Method: "POST",
},
{
Path: "/notice/notification/markAsRead",
Description: "标记通知已读",
ApiGroup: "通知管理",
Method: "POST",
},
{
Path: "/notice/notification/getNotificationStats",
Description: "获取通知统计",
ApiGroup: "通知管理",
Method: "GET",
},
// 在线用户管理相关API
{
Path: "/notice/onlineUser/removeOnlineUser/:userId",
Description: "移除在线用户",
ApiGroup: "在线用户管理",
Method: "DELETE",
},
{
Path: "/notice/onlineUser/cleanOfflineUsers",
Description: "清理离线用户",
ApiGroup: "在线用户管理",
Method: "POST",
},
{
Path: "/notice/onlineUser/getOnlineUsers",
Description: "获取在线用户列表",
ApiGroup: "在线用户管理",
Method: "GET",
},
{
Path: "/notice/onlineUser/getOnlineUsers",
Description: "获取在线用户列表(分页)",
ApiGroup: "在线用户管理",
Method: "POST",
},
{
Path: "/notice/onlineUser/getOnlineUsersByRole/:roleId",
Description: "根据角色获取在线用户",
ApiGroup: "在线用户管理",
Method: "GET",
},
{
Path: "/notice/onlineUser/checkUserOnline/:userId",
Description: "检查用户是否在线",
ApiGroup: "在线用户管理",
Method: "GET",
},
{
Path: "/notice/onlineUser/getOnlineUserCount",
Description: "获取在线用户数量",
ApiGroup: "在线用户管理",
Method: "GET",
},
{
Path: "/notice/onlineUser/getOnlineUserStats",
Description: "获取在线用户统计数据",
ApiGroup: "在线用户管理",
Method: "GET",
},
// Socket.IO相关API
{
Path: "/socket.io/*any",
Description: "Socket.IO连接",
ApiGroup: "实时通信",
Method: "GET",
},
{
Path: "/socket.io/*any",
Description: "Socket.IO连接",
ApiGroup: "实时通信",
Method: "POST",
},
}
utils.RegisterApis(entities...)
}
+23
View File
@@ -0,0 +1,23 @@
package initialize
import (
"context"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model"
"github.com/pkg/errors"
"go.uber.org/zap"
)
func Gorm(ctx context.Context) {
err := global.GVA_DB.WithContext(ctx).AutoMigrate(
&model.Notification{},
&model.UserRead{},
&model.OnlineUser{},
)
if err != nil {
err = errors.Wrap(err, "注册表失败!")
zap.L().Error(fmt.Sprintf("%+v", err))
}
}
+66
View File
@@ -0,0 +1,66 @@
package initialize
import (
"context"
model "github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/plugin-tool/utils"
)
func Menu(ctx context.Context) {
entities := []model.SysBaseMenu{
{
MenuLevel: 0,
ParentId: 0,
Path: "notice",
Name: "notice",
Hidden: false,
Component: "view/routerHolder.vue",
Sort: 5,
Meta: model.Meta{
Title: "通知中心",
Icon: "notification",
},
},
{
MenuLevel: 0,
ParentId: 0, // 这里需要在运行时动态设置为父菜单ID
Path: "notificationManage",
Name: "notificationManage",
Hidden: false,
Component: "view/notice/notification/index.vue",
Sort: 1,
Meta: model.Meta{
Title: "通知管理",
Icon: "message",
},
},
{
MenuLevel: 0,
ParentId: 0, // 这里需要在运行时动态设置为父菜单ID
Path: "onlineUserManage",
Name: "onlineUserManage",
Hidden: false,
Component: "view/notice/onlineUser/index.vue",
Sort: 2,
Meta: model.Meta{
Title: "在线用户",
Icon: "user",
},
},
{
MenuLevel: 0,
ParentId: 0, // 这里需要在运行时动态设置为父菜单ID
Path: "userCenter",
Name: "userCenter",
Hidden: false,
Component: "view/notice/userCenter/index.vue",
Sort: 3,
Meta: model.Meta{
Title: "我的通知",
Icon: "bell",
},
},
}
utils.RegisterMenus(entities...)
}
+27
View File
@@ -0,0 +1,27 @@
package initialize
import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/middleware"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/router"
"github.com/gin-gonic/gin"
)
func Router(engine *gin.Engine) {
public := engine.Group(global.GVA_CONFIG.System.RouterPrefix).Group("")
private := engine.Group(global.GVA_CONFIG.System.RouterPrefix).Group("")
private.Use(middleware.JWTAuth()).Use(middleware.CasbinHandler())
// 初始化通知中心路由
noticeGroup := private.Group("notice")
{
// 初始化通知管理路由
router.RouterGroupApp.NotificationRouter.InitNotificationRouter(noticeGroup)
// 初始化在线用户管理路由
router.RouterGroupApp.OnlineUserRouter.InitOnlineUserRouter(noticeGroup)
// 初始化Socket.IO路由
router.RouterGroupApp.SocketRouter.InitSocketRouter(public)
}
}
+17
View File
@@ -0,0 +1,17 @@
package initialize
import (
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/plugin"
"github.com/pkg/errors"
"go.uber.org/zap"
)
func Viper() {
err := global.GVA_VP.UnmarshalKey("notice", &plugin.Config)
if err != nil {
err = errors.Wrap(err, "初始化配置文件失败!")
zap.L().Error(fmt.Sprintf("%+v", err))
}
}
@@ -0,0 +1,26 @@
package model
import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"time"
)
// Notification 通知信息模型
type Notification struct {
global.GVA_MODEL
Title string `json:"title" gorm:"column:title;type:varchar(255);not null;comment:通知标题"`
Content string `json:"content" gorm:"column:content;type:text;not null;comment:通知内容"`
Type string `json:"type" gorm:"column:type;type:varchar(50);not null;default:system;comment:通知类型:system-系统通知,personal-个人通知,role-角色通知"`
Priority string `json:"priority" gorm:"column:priority;type:varchar(20);not null;default:normal;comment:通知优先级:low-低,normal-普通,high-高,urgent-紧急"`
SenderId uint `json:"senderId" gorm:"column:sender_id;not null;comment:发送者用户ID"`
TargetType string `json:"targetType" gorm:"column:target_type;type:varchar(20);not null;default:all;comment:目标类型:all-全部用户,user-指定用户,role-指定角色"`
TargetIds string `json:"targetIds" gorm:"column:target_ids;type:text;comment:目标用户或角色ID列表,JSON格式"`
Status string `json:"status" gorm:"column:status;type:varchar(20);not null;default:draft;comment:通知状态:draft-草稿,published-已发布,expired-已过期"`
PublishTime *time.Time `json:"publishTime" gorm:"column:publish_time;comment:通知发布时间"`
ExpireTime *time.Time `json:"expireTime" gorm:"column:expire_time;comment:通知过期时间"`
}
// TableName 设置表名
func (Notification) TableName() string {
return "notice_notifications"
}
+20
View File
@@ -0,0 +1,20 @@
package model
import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"time"
)
// OnlineUser 在线用户模型
type OnlineUser struct {
global.GVA_MODEL
UserId uint `json:"userId" gorm:"column:user_id;not null;uniqueIndex;comment:用户ID"`
SocketId string `json:"socketId" gorm:"column:socket_id;type:varchar(255);not null;comment:Socket连接ID"`
LastActiveTime time.Time `json:"lastActiveTime" gorm:"column:last_active_time;not null;comment:最后活跃时间"`
Status string `json:"status" gorm:"column:status;type:varchar(20);not null;default:online;comment:在线状态:online-在线,offline-离线"`
}
// TableName 设置表名
func (OnlineUser) TableName() string {
return "notice_online_users"
}
@@ -0,0 +1,71 @@
package request
import (
"github.com/flipped-aurora/gin-vue-admin/server/model/common/request"
"time"
)
// NotificationSearch 通知搜索请求
type NotificationSearch struct {
request.PageInfo
Title string `json:"title" form:"title"`
Type string `json:"type" form:"type"`
Priority string `json:"priority" form:"priority"`
Status string `json:"status" form:"status"`
SenderId uint `json:"senderId" form:"senderId"`
TargetType string `json:"targetType" form:"targetType"`
StartTime *time.Time `json:"startTime" form:"startTime"`
EndTime *time.Time `json:"endTime" form:"endTime"`
}
// CreateNotificationRequest 创建通知请求
type CreateNotificationRequest struct {
Title string `json:"title" binding:"required" form:"title"`
Content string `json:"content" binding:"required" form:"content"`
Type string `json:"type" binding:"required" form:"type"`
Priority string `json:"priority" form:"priority"`
TargetType string `json:"targetType" binding:"required" form:"targetType"`
TargetIds []uint `json:"targetIds" form:"targetIds"`
PublishTime *time.Time `json:"publishTime" form:"publishTime"`
ExpireTime *time.Time `json:"expireTime" form:"expireTime"`
}
// UpdateNotificationRequest 更新通知请求
type UpdateNotificationRequest struct {
ID uint `json:"id" binding:"required"`
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Type string `json:"type" binding:"required"`
Priority string `json:"priority" binding:"required"`
TargetType string `json:"targetType" binding:"required"`
TargetIds []uint `json:"targetIds"`
Status string `json:"status" binding:"required"`
PublishTime *time.Time `json:"publishTime"`
ExpireTime *time.Time `json:"expireTime"`
}
// SendNotificationRequest 发送通知请求
type SendNotificationRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Type string `json:"type" binding:"required"`
Priority string `json:"priority" binding:"required"`
TargetType string `json:"targetType" binding:"required"`
TargetIds []uint `json:"targetIds"`
RoleIds []uint `json:"roleIds"`
}
// MarkReadRequest 标记已读请求
type MarkReadRequest struct {
NotificationIds []uint `json:"notificationIds" binding:"required"`
}
// UserNotificationSearch 用户通知搜索请求
type UserNotificationSearch struct {
request.PageInfo
IsRead *bool `json:"isRead" form:"isRead"`
Type string `json:"type" form:"type"`
Priority string `json:"priority" form:"priority"`
StartTime *time.Time `json:"startTime" form:"startTime"`
EndTime *time.Time `json:"endTime" form:"endTime"`
}
@@ -0,0 +1,11 @@
package request
import "github.com/flipped-aurora/gin-vue-admin/server/model/common/request"
// OnlineUserSearch 在线用户搜索请求
type OnlineUserSearch struct {
request.PageInfo
Username string `json:"username" form:"username"` // 用户名搜索
NickName string `json:"nickName" form:"nickName"` // 昵称搜索
RoleId uint `json:"roleId" form:"roleId"` // 角色ID过滤
}
@@ -0,0 +1,73 @@
package response
import (
"time"
)
// NotificationResponse 通知响应
type NotificationResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Type string `json:"type"`
Priority string `json:"priority"`
SenderId uint `json:"senderId"`
SenderName string `json:"senderName"`
TargetType string `json:"targetType"`
TargetIds string `json:"targetIds"`
Status string `json:"status"`
PublishTime *time.Time `json:"publishTime"`
ExpireTime *time.Time `json:"expireTime"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// UserNotificationResponse 用户通知响应
type UserNotificationResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Type string `json:"type"`
Priority string `json:"priority"`
SenderId uint `json:"senderId"`
SenderName string `json:"senderName"`
IsRead bool `json:"isRead"`
ReadTime *time.Time `json:"readTime"`
PublishTime *time.Time `json:"publishTime"`
ExpireTime *time.Time `json:"expireTime"`
CreatedAt time.Time `json:"createdAt"`
}
// NotificationStatsResponse 通知统计响应
type NotificationStatsResponse struct {
TotalCount int64 `json:"totalCount"`
UnreadCount int64 `json:"unreadCount"`
ReadCount int64 `json:"readCount"`
}
// OnlineUserResponse 在线用户响应
type OnlineUserResponse struct {
UserId uint `json:"userId"` // 用户ID
Username string `json:"username"` // 用户名
NickName string `json:"nickName"` // 昵称
SocketId string `json:"socketId"` // Socket连接ID
LastActiveTime time.Time `json:"lastActiveTime"` // 最后活跃时间
Status string `json:"status"` // 状态
OnlineDuration int64 `json:"onlineDuration"` // 在线时长(分钟)
}
// OnlineUserStatsResponse 在线用户统计响应结构体
type OnlineUserStatsResponse struct {
TotalOnline int64 `json:"totalOnline"` // 当前在线总数
TodayLogin int64 `json:"todayLogin"` // 今日登录人数
PeakOnline int64 `json:"peakOnline"` // 峰值在线人数
AverageOnline int64 `json:"averageOnline"` // 平均在线人数
}
// SendNotificationResponse 发送通知响应
type SendNotificationResponse struct {
NotificationId uint `json:"notificationId"`
SuccessCount int `json:"successCount"`
FailCount int `json:"failCount"`
Message string `json:"message"`
}
+20
View File
@@ -0,0 +1,20 @@
package model
import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"time"
)
// UserRead 用户通知读取状态模型
type UserRead struct {
global.GVA_MODEL
UserId uint `json:"userId" gorm:"column:user_id;not null;index;comment:用户ID"`
NotificationId uint `json:"notificationId" gorm:"column:notification_id;not null;index;comment:通知ID"`
IsRead bool `json:"isRead" gorm:"column:is_read;not null;default:false;comment:是否已读"`
ReadTime *time.Time `json:"readTime" gorm:"column:read_time;comment:通知读取时间"`
}
// TableName 设置表名
func (UserRead) TableName() string {
return "notice_user_reads"
}
+48
View File
@@ -0,0 +1,48 @@
package notice
import (
"context"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/initialize"
interfaces "github.com/flipped-aurora/gin-vue-admin/server/utils/plugin/v2"
"github.com/gin-gonic/gin"
)
var _ interfaces.Plugin = (*plugin)(nil)
var Plugin = new(plugin)
type plugin struct{}
// Register 注册插件
func (p *plugin) Register(group *gin.Engine) {
ctx := context.Background()
// 初始化数据库
initialize.Gorm(ctx)
// 初始化路由
initialize.Router(group)
// 初始化菜单
initialize.Menu(ctx)
// 初始化 api
initialize.Api(ctx)
}
// RouterPath 返回插件路由路径
func (p *plugin) RouterPath() string {
return "notice"
}
// Name 返回插件名称
func (p *plugin) Name() string {
return "notice"
}
// Description 返回插件描述
func (p *plugin) Description() string {
return "通知中心插件,提供实时通知、在线用户管理等功能"
}
// Version 返回插件版本
func (p *plugin) Version() string {
return "v1.0.0"
}
+5
View File
@@ -0,0 +1,5 @@
package plugin
import "github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/config"
var Config config.Config
+9
View File
@@ -0,0 +1,9 @@
package router
type RouterGroup struct {
NotificationRouter
OnlineUserRouter
SocketRouter
}
var RouterGroupApp = new(RouterGroup)
@@ -0,0 +1,41 @@
package router
import (
"github.com/flipped-aurora/gin-vue-admin/server/middleware"
noticeApi "github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/api"
"github.com/gin-gonic/gin"
)
type NotificationRouter struct{}
// InitNotificationRouter 初始化通知路由
func (r *NotificationRouter) InitNotificationRouter(Router *gin.RouterGroup) {
notificationRouter := Router.Group("notification")
notificationRouterWithoutRecord := Router.Group("notification")
// 需要记录操作日志的路由
notificationRouter.Use(middleware.OperationRecord())
{
// 管理员接口
notificationRouter.POST("createNotification", noticeApi.ApiGroupApp.NotificationApi.CreateNotification) // 创建通知
notificationRouter.PUT("updateNotification", noticeApi.ApiGroupApp.NotificationApi.UpdateNotification) // 更新通知
notificationRouter.DELETE("deleteNotification", noticeApi.ApiGroupApp.NotificationApi.DeleteNotification) // 删除通知
notificationRouter.POST("publishNotification/:id", noticeApi.ApiGroupApp.NotificationApi.PublishNotification) // 发布通知
notificationRouter.POST("sendNotification", noticeApi.ApiGroupApp.NotificationApi.SendNotification) // 发送通知
}
// 不需要记录操作日志的路由
notificationRouterWithoutRecord.Use(middleware.JWTAuth())
{
// 查询接口
notificationRouterWithoutRecord.POST("getNotificationList", noticeApi.ApiGroupApp.NotificationApi.GetNotificationList) // 获取通知列表
notificationRouterWithoutRecord.GET("getNotificationById/:id", noticeApi.ApiGroupApp.NotificationApi.GetNotificationById) // 根据ID获取通知
// 用户接口
notificationRouterWithoutRecord.POST("getUserNotifications", noticeApi.ApiGroupApp.NotificationApi.GetUserNotifications) // 获取用户通知列表
notificationRouterWithoutRecord.POST("markAsRead", noticeApi.ApiGroupApp.NotificationApi.MarkNotificationAsRead) // 标记已读
notificationRouterWithoutRecord.GET("getNotificationStats", noticeApi.ApiGroupApp.NotificationApi.GetNotificationStats) // 获取通知统计
notificationRouterWithoutRecord.DELETE("deleteUserNotification/:notificationId", noticeApi.ApiGroupApp.NotificationApi.DeleteUserNotification) // 删除用户通知
}
}
@@ -0,0 +1,36 @@
package router
import (
"github.com/flipped-aurora/gin-vue-admin/server/middleware"
noticeApi "github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/api"
"github.com/gin-gonic/gin"
)
type OnlineUserRouter struct{}
// InitOnlineUserRouter 初始化在线用户路由
func (r *OnlineUserRouter) InitOnlineUserRouter(Router *gin.RouterGroup) {
onlineUserRouter := Router.Group("onlineUser")
onlineUserRouterWithoutRecord := Router.Group("onlineUser")
// 需要记录操作日志的路由
onlineUserRouter.Use(middleware.OperationRecord())
{
// 管理员接口
onlineUserRouter.DELETE("removeOnlineUser/:userId", noticeApi.ApiGroupApp.OnlineUserApi.RemoveOnlineUser) // 移除在线用户
onlineUserRouter.POST("cleanOfflineUsers", noticeApi.ApiGroupApp.OnlineUserApi.CleanOfflineUsers) // 清理离线用户
}
// 不需要记录操作日志的路由
onlineUserRouterWithoutRecord.Use(middleware.JWTAuth())
{
// 查询接口
onlineUserRouterWithoutRecord.GET("getOnlineUsers", noticeApi.ApiGroupApp.OnlineUserApi.GetOnlineUsers) // 获取在线用户列表
onlineUserRouterWithoutRecord.POST("getOnlineUsers", noticeApi.ApiGroupApp.OnlineUserApi.GetOnlineUsersWithPagination) // 获取在线用户列表(分页)
onlineUserRouterWithoutRecord.GET("getOnlineUsersByRole/:roleId", noticeApi.ApiGroupApp.OnlineUserApi.GetOnlineUsersByRole) // 根据角色获取在线用户
onlineUserRouterWithoutRecord.GET("checkUserOnline/:userId", noticeApi.ApiGroupApp.OnlineUserApi.CheckUserOnline) // 检查用户是否在线
onlineUserRouterWithoutRecord.GET("getOnlineUserCount", noticeApi.ApiGroupApp.OnlineUserApi.GetOnlineUserCount) // 获取在线用户数量
onlineUserRouterWithoutRecord.GET("getOnlineUserStats", noticeApi.ApiGroupApp.OnlineUserApi.GetOnlineUserStats) // 获取在线用户统计数据
}
}
@@ -0,0 +1,169 @@
package router
import (
"github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"go.uber.org/zap"
"strconv"
"time"
"github.com/doquangtan/socketio/v4"
"github.com/flipped-aurora/gin-vue-admin/server/global"
noticeService "github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/service"
"github.com/gin-gonic/gin"
)
type SocketRouter struct{}
var (
onlineUserService = noticeService.ServiceGroupApp.OnlineUserService
socketService = noticeService.ServiceGroupApp.SocketService
)
// InitSocketRouter 初始化Socket.IO路由
func (r *SocketRouter) InitSocketRouter(Router *gin.RouterGroup) {
// 创建Socket.IO服务器
io := socketio.New()
var claims *request.CustomClaims
// 认证中间件
io.OnAuthentication(func(params map[string]string) bool {
// 从参数获取token
token, ok := params["token"]
if !ok || token == "" {
return false
}
j := utils.NewJWT()
// parseToken 解析token包含的信息
claimsP, err := j.ParseToken(token)
if err != nil {
zap.L().Error("JWT解析失败: " + err.Error())
return false
}
claims = claimsP
return true
})
// 连接事件
io.OnConnection(func(socket *socketio.Socket) {
if claims == nil {
global.GVA_LOG.Error("用户认证失败,无法建立连接")
socket.Disconnect()
return
}
userInfo := claims
userID := userInfo.BaseClaims.ID
socketID := socket.Id
// 加入用户房间
userRoom := "user_" + strconv.Itoa(int(userID))
socket.Join(userRoom)
// 添加到在线用户列表
err := onlineUserService.AddOnlineUser(userID, socketID)
if err != nil {
global.GVA_LOG.Error("添加在线用户失败: " + err.Error())
}
// 添加到Socket连接管理
socketService.AddConnection(userID, socketID, socket)
// 记录用户连接信息
global.GVA_LOG.Info("用户连接详情: UserID=" + strconv.Itoa(int(userID)) +
", SocketID=" + socketID)
// 发送用户上线通知
socket.Emit("user_online", map[string]interface{}{
"userId": userID,
"socketId": socketID,
"timestamp": time.Now(),
"message": "连接成功",
})
global.GVA_LOG.Info("用户连接成功: UserID=" + strconv.Itoa(int(userID)) + ", SocketID=" + socketID)
// 监听心跳事件
socket.On("heartbeat", func(event *socketio.EventPayload) {
// 更新用户活跃时间
err := onlineUserService.UpdateUserActivity(userID)
if err != nil {
global.GVA_LOG.Error("更新用户活跃时间失败: " + err.Error())
}
// 回复心跳
socket.Emit("heartbeat_reply", map[string]interface{}{
"timestamp": time.Now(),
"status": "ok",
})
})
// 监听通知已读事件
socket.On("mark_notification_read", func(event *socketio.EventPayload) {
if event.Data != nil && len(event.Data) > 0 {
if dataMap, ok := event.Data[0].(map[string]interface{}); ok {
if notificationIds, exists := dataMap["notificationIds"]; exists {
if ids, ok := notificationIds.([]interface{}); ok {
var uintIds []uint
for _, id := range ids {
if idFloat, ok := id.(float64); ok {
uintIds = append(uintIds, uint(idFloat))
}
}
// 标记通知为已读
notificationService := noticeService.ServiceGroupApp.NotificationService
err := notificationService.MarkNotificationsAsRead(userID, uintIds)
if err != nil {
global.GVA_LOG.Error("标记通知已读失败: " + err.Error())
socket.Emit("error", map[string]interface{}{
"message": "标记通知已读失败",
"error": err.Error(),
})
return
}
// 发送成功响应
socket.Emit("notification_read_success", map[string]interface{}{
"notificationIds": uintIds,
"timestamp": time.Now(),
})
}
}
}
}
})
// 监听获取通知统计事件
socket.On("get_notification_stats", func(event *socketio.EventPayload) {
notificationService := noticeService.ServiceGroupApp.NotificationService
stats, err := notificationService.GetNotificationStats(userID)
if err != nil {
global.GVA_LOG.Error("获取通知统计失败: " + err.Error())
socket.Emit("error", map[string]interface{}{
"message": "获取通知统计失败",
"error": err.Error(),
})
return
}
socket.Emit("notification_stats", stats)
})
// 监听断开连接事件
socket.On("disconnect", func(event *socketio.EventPayload) {
// 从在线用户列表移除
err := onlineUserService.RemoveOnlineUser(userID)
if err != nil {
global.GVA_LOG.Error("移除在线用户失败: " + err.Error())
}
// 从Socket连接管理移除
socketService.RemoveConnectionByUserId(userID)
global.GVA_LOG.Info("用户断开连接: UserID=" + strconv.Itoa(int(userID)) + ", SocketID=" + socketID)
})
})
// 注册Socket.IO路由
Router.GET("/socket.io/*any", gin.WrapH(io.HttpHandler()))
Router.POST("/socket.io/*any", gin.WrapH(io.HttpHandler()))
}
+16
View File
@@ -0,0 +1,16 @@
package service
import "github.com/doquangtan/socketio/v4"
type ServiceGroup struct {
NotificationService
OnlineUserService
SocketService
}
var ServiceGroupApp = &ServiceGroup{
SocketService: SocketService{
connections: make(map[string]*socketio.Socket),
userSockets: make(map[uint]string),
},
}
@@ -0,0 +1,507 @@
package service
import (
"encoding/json"
"fmt"
"time"
"github.com/flipped-aurora/gin-vue-admin/server/global"
systemModel "github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/request"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/response"
)
type NotificationService struct{}
// CreateNotification 创建通知
func (s *NotificationService) CreateNotification(req request.CreateNotificationRequest, senderId uint) (notification model.Notification, err error) {
// 将 []uint 类型的 TargetIds 转换为 JSON 字符串
targetIdsJson := ""
if len(req.TargetIds) > 0 {
targetIdsBytes, err := json.Marshal(req.TargetIds)
if err != nil {
return notification, fmt.Errorf("序列化目标用户ID失败: %v", err)
}
targetIdsJson = string(targetIdsBytes)
}
notification = model.Notification{
Title: req.Title,
Content: req.Content,
Type: req.Type,
Priority: req.Priority,
SenderId: senderId,
TargetType: req.TargetType,
TargetIds: targetIdsJson,
Status: "draft",
PublishTime: req.PublishTime,
ExpireTime: req.ExpireTime,
}
err = global.GVA_DB.Create(&notification).Error
return notification, err
}
// UpdateNotification 更新通知
func (s *NotificationService) UpdateNotification(req request.UpdateNotificationRequest) error {
var notification model.Notification
if err := global.GVA_DB.First(&notification, req.ID).Error; err != nil {
return err
}
// 将 []uint 类型的 TargetIds 转换为 JSON 字符串
targetIdsJson := ""
if len(req.TargetIds) > 0 {
targetIdsBytes, err := json.Marshal(req.TargetIds)
if err != nil {
return fmt.Errorf("序列化目标用户ID失败: %v", err)
}
targetIdsJson = string(targetIdsBytes)
}
updates := map[string]interface{}{
"title": req.Title,
"content": req.Content,
"type": req.Type,
"priority": req.Priority,
"target_type": req.TargetType,
"target_ids": targetIdsJson,
"status": req.Status,
"publish_time": req.PublishTime,
"expire_time": req.ExpireTime,
}
return global.GVA_DB.Model(&notification).Updates(updates).Error
}
// DeleteNotification 删除通知
func (s *NotificationService) DeleteNotification(id uint) error {
return global.GVA_DB.Delete(&model.Notification{}, id).Error
}
// GetNotificationList 获取通知列表
func (s *NotificationService) GetNotificationList(req request.NotificationSearch) (list []response.NotificationResponse, total int64, err error) {
limit := req.PageSize
offset := req.PageSize * (req.Page - 1)
db := global.GVA_DB.Model(&model.Notification{})
// 构建查询条件
if req.Title != "" {
db = db.Where("title LIKE ?", "%"+req.Title+"%")
}
if req.Type != "" {
db = db.Where("type = ?", req.Type)
}
if req.Priority != "" {
db = db.Where("priority = ?", req.Priority)
}
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.SenderId != 0 {
db = db.Where("sender_id = ?", req.SenderId)
}
if req.TargetType != "" {
db = db.Where("target_type = ?", req.TargetType)
}
if req.StartTime != nil {
db = db.Where("created_at >= ?", req.StartTime)
}
if req.EndTime != nil {
db = db.Where("created_at <= ?", req.EndTime)
}
err = db.Count(&total).Error
if err != nil {
return
}
var notifications []model.Notification
err = db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&notifications).Error
if err != nil {
return
}
// 获取发送者信息
var senderIds []uint
for _, notification := range notifications {
senderIds = append(senderIds, notification.SenderId)
}
var users []systemModel.SysUser
userMap := make(map[uint]string)
if len(senderIds) > 0 {
global.GVA_DB.Where("id IN ?", senderIds).Find(&users)
for _, user := range users {
userMap[user.ID] = user.Username
}
}
// 构建响应数据
for _, notification := range notifications {
senderName := userMap[notification.SenderId]
list = append(list, response.NotificationResponse{
ID: notification.ID,
Title: notification.Title,
Content: notification.Content,
Type: notification.Type,
Priority: notification.Priority,
SenderId: notification.SenderId,
SenderName: senderName,
TargetType: notification.TargetType,
TargetIds: notification.TargetIds,
Status: notification.Status,
PublishTime: notification.PublishTime,
ExpireTime: notification.ExpireTime,
CreatedAt: notification.CreatedAt,
UpdatedAt: notification.UpdatedAt,
})
}
return list, total, err
}
// GetNotificationById 根据ID获取通知
func (s *NotificationService) GetNotificationById(id uint) (notification model.Notification, err error) {
err = global.GVA_DB.First(&notification, id).Error
return
}
// PublishNotification 发布通知
func (s *NotificationService) PublishNotification(id uint) error {
// 获取通知详情
var notification model.Notification
if err := global.GVA_DB.First(&notification, id).Error; err != nil {
return err
}
// 检查通知是否已经发布
if notification.Status == "published" {
return fmt.Errorf("通知已经发布")
}
// 更新通知状态为已发布
now := time.Now()
if err := global.GVA_DB.Model(&model.Notification{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": "published",
"publish_time": &now,
}).Error; err != nil {
return err
}
// 重新获取更新后的通知信息
if err := global.GVA_DB.First(&notification, id).Error; err != nil {
return err
}
// 根据目标类型创建用户读取记录
var targetUserIds []uint
var targetIds []uint
if notification.TargetIds != "" {
json.Unmarshal([]byte(notification.TargetIds), &targetIds)
}
switch notification.TargetType {
case "all":
// 获取所有用户ID
var users []systemModel.SysUser
global.GVA_DB.Select("id").Find(&users)
for _, user := range users {
targetUserIds = append(targetUserIds, user.ID)
}
case "users":
targetUserIds = targetIds
case "roles":
// 根据角色获取用户ID
if len(targetIds) > 0 {
var users []systemModel.SysUser
global.GVA_DB.Joins("JOIN sys_user_authority ON sys_users.id = sys_user_authority.sys_user_id").
Where("sys_user_authority.sys_authority_id IN ?", targetIds).
Select("DISTINCT sys_users.id").
Find(&users)
for _, user := range users {
targetUserIds = append(targetUserIds, user.ID)
}
}
}
// 批量创建用户读取记录
var userReads []model.UserRead
for _, userId := range targetUserIds {
userReads = append(userReads, model.UserRead{
UserId: userId,
NotificationId: notification.ID,
IsRead: false,
})
}
if len(userReads) > 0 {
if err := global.GVA_DB.CreateInBatches(userReads, 100).Error; err != nil {
return fmt.Errorf("创建用户读取记录失败: %v", err)
}
}
// 通过Socket实时推送通知给在线用户
socketService := ServiceGroupApp.SocketService
switch notification.TargetType {
case "all":
// 广播给所有在线用户
socketService.BroadcastNotification(&notification)
default:
// 发送给指定的在线用户
if len(targetUserIds) > 0 {
socketService.SendNotificationToUsers(targetUserIds, &notification)
}
}
return nil
}
// SendNotification 发送通知
func (s *NotificationService) SendNotification(req request.SendNotificationRequest, senderId uint) (response.SendNotificationResponse, error) {
var result response.SendNotificationResponse
// 创建通知记录
targetIds, _ := json.Marshal(req.TargetIds)
notification := model.Notification{
Title: req.Title,
Content: req.Content,
Type: req.Type,
Priority: req.Priority,
SenderId: senderId,
TargetType: req.TargetType,
TargetIds: string(targetIds),
Status: "published",
PublishTime: &time.Time{},
}
now := time.Now()
notification.PublishTime = &now
if err := global.GVA_DB.Create(&notification).Error; err != nil {
return result, err
}
result.NotificationId = notification.ID
// 根据目标类型创建用户读取记录
var targetUserIds []uint
switch req.TargetType {
case "all":
// 获取所有用户ID
var users []systemModel.SysUser
global.GVA_DB.Select("id").Find(&users)
for _, user := range users {
targetUserIds = append(targetUserIds, user.ID)
}
case "user":
targetUserIds = req.TargetIds
case "role":
// 根据角色获取用户ID
if len(req.RoleIds) > 0 {
var users []systemModel.SysUser
global.GVA_DB.Joins("JOIN sys_user_authority ON sys_users.id = sys_user_authority.sys_user_id").
Where("sys_user_authority.sys_authority_id IN ?", req.RoleIds).
Select("DISTINCT sys_users.id").
Find(&users)
for _, user := range users {
targetUserIds = append(targetUserIds, user.ID)
}
}
}
// 批量创建用户读取记录
var userReads []model.UserRead
for _, userId := range targetUserIds {
userReads = append(userReads, model.UserRead{
UserId: userId,
NotificationId: notification.ID,
IsRead: false,
})
}
if len(userReads) > 0 {
if err := global.GVA_DB.CreateInBatches(userReads, 100).Error; err != nil {
result.FailCount = len(userReads)
result.Message = "创建用户读取记录失败: " + err.Error()
return result, err
}
result.SuccessCount = len(userReads)
}
result.Message = fmt.Sprintf("通知发送成功,共发送给 %d 个用户", result.SuccessCount)
return result, nil
}
// GetUserNotifications 获取用户通知列表
func (s *NotificationService) GetUserNotifications(userId uint, req request.UserNotificationSearch) (list []response.UserNotificationResponse, total int64, err error) {
limit := req.PageSize
offset := req.PageSize * (req.Page - 1)
db := global.GVA_DB.Table("notice_user_reads ur").
Select("n.id, n.title, n.content, n.type, n.priority, n.sender_id, n.publish_time, n.expire_time, n.created_at, ur.is_read, ur.read_time").
Joins("JOIN notice_notifications n ON ur.notification_id = n.id").
Where("ur.user_id = ? AND n.status = 'published'", userId)
// 构建查询条件
if req.IsRead != nil {
db = db.Where("ur.is_read = ?", *req.IsRead)
}
if req.Type != "" {
db = db.Where("n.type = ?", req.Type)
}
if req.Priority != "" {
db = db.Where("n.priority = ?", req.Priority)
}
if req.StartTime != nil {
db = db.Where("n.created_at >= ?", req.StartTime)
}
if req.EndTime != nil {
db = db.Where("n.created_at <= ?", req.EndTime)
}
err = db.Count(&total).Error
if err != nil {
return
}
var results []struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Type string `json:"type"`
Priority string `json:"priority"`
SenderId uint `json:"sender_id"`
PublishTime *time.Time `json:"publish_time"`
ExpireTime *time.Time `json:"expire_time"`
CreatedAt time.Time `json:"created_at"`
IsRead bool `json:"is_read"`
ReadTime *time.Time `json:"read_time"`
}
err = db.Limit(limit).Offset(offset).Order("n.created_at DESC").Scan(&results).Error
if err != nil {
return
}
// 获取发送者信息
var senderIds []uint
for _, result := range results {
senderIds = append(senderIds, result.SenderId)
}
var users []systemModel.SysUser
userMap := make(map[uint]string)
if len(senderIds) > 0 {
global.GVA_DB.Where("id IN ?", senderIds).Find(&users)
for _, user := range users {
userMap[user.ID] = user.Username
}
}
// 构建响应数据
for _, result := range results {
senderName := userMap[result.SenderId]
list = append(list, response.UserNotificationResponse{
ID: result.ID,
Title: result.Title,
Content: result.Content,
Type: result.Type,
Priority: result.Priority,
SenderId: result.SenderId,
SenderName: senderName,
IsRead: result.IsRead,
ReadTime: result.ReadTime,
PublishTime: result.PublishTime,
ExpireTime: result.ExpireTime,
CreatedAt: result.CreatedAt,
})
}
return list, total, err
}
// MarkNotificationsAsRead 标记通知为已读
func (s *NotificationService) MarkNotificationsAsRead(userId uint, notificationIds []uint) error {
now := time.Now()
return global.GVA_DB.Model(&model.UserRead{}).
Where("user_id = ? AND notification_id IN ?", userId, notificationIds).
Updates(map[string]interface{}{
"is_read": true,
"read_time": &now,
}).Error
}
// GetNotificationStats 获取用户通知统计
func (s *NotificationService) GetNotificationStats(userId uint) (response.NotificationStatsResponse, error) {
var stats response.NotificationStatsResponse
// 总通知数
err := global.GVA_DB.Table("notice_user_reads ur").
Joins("JOIN notice_notifications n ON ur.notification_id = n.id").
Where("ur.user_id = ? AND n.status = 'published'", userId).
Count(&stats.TotalCount).Error
if err != nil {
return stats, err
}
// 未读通知数
err = global.GVA_DB.Table("notice_user_reads ur").
Joins("JOIN notice_notifications n ON ur.notification_id = n.id").
Where("ur.user_id = ? AND ur.is_read = false AND n.status = 'published'", userId).
Count(&stats.UnreadCount).Error
if err != nil {
return stats, err
}
stats.ReadCount = stats.TotalCount - stats.UnreadCount
return stats, nil
}
// DeleteUserNotification 删除用户通知记录
// 如果通知只针对单个用户,则删除通知本身;否则只删除用户读取记录
func (s *NotificationService) DeleteUserNotification(userId uint, notificationId uint) error {
// 首先检查通知是否存在
var notification model.Notification
err := global.GVA_DB.First(&notification, notificationId).Error
if err != nil {
return fmt.Errorf("通知不存在")
}
// 检查通知的接收者数量
var receiverCount int64
err = global.GVA_DB.Model(&model.UserRead{}).
Where("notification_id = ?", notificationId).
Count(&receiverCount).Error
if err != nil {
return err
}
// 如果只有一个接收者,删除通知本身
if receiverCount == 1 {
// 开启事务
tx := global.GVA_DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 删除用户读取记录
if err := tx.Where("notification_id = ? AND user_id = ?", notificationId, userId).Delete(&model.UserRead{}).Error; err != nil {
tx.Rollback()
return err
}
// 删除通知本身
if err := tx.Delete(&notification).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
} else {
// 如果有多个接收者,只删除当前用户的读取记录
return global.GVA_DB.Where("notification_id = ? AND user_id = ?", notificationId, userId).Delete(&model.UserRead{}).Error
}
}
@@ -0,0 +1,261 @@
package service
import (
"github.com/pkg/errors"
"time"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/request"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/response"
"gorm.io/gorm"
)
type OnlineUserService struct{}
// AddOnlineUser 添加在线用户
func (s *OnlineUserService) AddOnlineUser(userId uint, socketId string) error {
// 使用事务确保操作的原子性
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
// 先硬删除该用户的所有在线记录(包括软删除的记录)
if err := tx.Unscoped().Where("user_id = ?", userId).Delete(&model.OnlineUser{}).Error; err != nil {
return err
}
// 创建新的在线记录
onlineUser := model.OnlineUser{
UserId: userId,
SocketId: socketId,
LastActiveTime: time.Now(),
Status: "online",
}
return tx.Create(&onlineUser).Error
})
}
// RemoveOnlineUser 移除在线用户
func (s *OnlineUserService) RemoveOnlineUser(userId uint) error {
// 使用硬删除确保用户立即从在线列表中移除
err := global.GVA_DB.Unscoped().Where("user_id = ?", userId).Delete(&model.OnlineUser{}).Error
if err != nil {
return errors.Wrap(err, "移除在线用户失败")
}
// 广播给所有在线用户
return ServiceGroupApp.SocketService.SendToUser(userId, "forceLogout", "您已被管理员强制下线,请重新登录")
}
// RemoveOnlineUserBySocketId 根据SocketId移除在线用户
func (s *OnlineUserService) RemoveOnlineUserBySocketId(socketId string) error {
// 使用硬删除确保用户立即从在线列表中移除
return global.GVA_DB.Unscoped().Where("socket_id = ?", socketId).Delete(&model.OnlineUser{}).Error
}
// UpdateUserActivity 更新用户活跃时间
func (s *OnlineUserService) UpdateUserActivity(userId uint) error {
return global.GVA_DB.Model(&model.OnlineUser{}).
Where("user_id = ?", userId).
Update("last_active_time", time.Now()).Error
}
// UpdateUserActivityBySocketId 根据SocketId更新用户活跃时间
func (s *OnlineUserService) UpdateUserActivityBySocketId(socketId string) error {
return global.GVA_DB.Model(&model.OnlineUser{}).
Where("socket_id = ?", socketId).
Update("last_active_time", time.Now()).Error
}
// GetOnlineUsers 获取在线用户列表
func (s *OnlineUserService) GetOnlineUsers() ([]response.OnlineUserResponse, error) {
var results []struct {
UserId uint `json:"user_id"`
Username string `json:"username"`
NickName string `json:"nick_name"`
SocketId string `json:"socket_id"`
LastActiveTime time.Time `json:"last_active_time"`
Status string `json:"status"`
}
err := global.GVA_DB.Table("notice_online_users ou").
Select("ou.user_id, u.username, u.nick_name, ou.socket_id, ou.last_active_time, ou.status").
Joins("JOIN sys_users u ON ou.user_id = u.id").
Where("ou.status = 'online'").
Order("ou.last_active_time DESC").
Scan(&results).Error
if err != nil {
return nil, err
}
var onlineUsers []response.OnlineUserResponse
for _, result := range results {
onlineUsers = append(onlineUsers, response.OnlineUserResponse{
UserId: result.UserId,
Username: result.Username,
NickName: result.NickName,
SocketId: result.SocketId,
LastActiveTime: result.LastActiveTime,
Status: result.Status,
})
}
return onlineUsers, nil
}
// GetOnlineUsersWithPagination 获取在线用户列表(分页)
func (s *OnlineUserService) GetOnlineUsersWithPagination(req request.OnlineUserSearch) ([]response.OnlineUserResponse, int64, error) {
limit := req.PageSize
offset := req.PageSize * (req.Page - 1)
// 构建查询条件
db := global.GVA_DB.Table("notice_online_users ou").
Select("ou.user_id, u.username, u.nick_name, ou.socket_id, ou.last_active_time, ou.status").
Joins("JOIN sys_users u ON ou.user_id = u.id").
Where("ou.status = 'online'")
// 添加搜索条件
if req.Username != "" {
db = db.Where("u.username LIKE ?", "%"+req.Username+"%")
}
if req.NickName != "" {
db = db.Where("u.nick_name LIKE ?", "%"+req.NickName+"%")
}
if req.RoleId != 0 {
// 通过角色过滤用户
db = db.Where("EXISTS (SELECT 1 FROM sys_user_authority sua WHERE sua.sys_user_id = u.id AND sua.sys_authority_authority_id = ?)", req.RoleId)
}
// 获取总数
var total int64
err := db.Count(&total).Error
if err != nil {
return nil, 0, err
}
// 获取分页数据
var results []struct {
UserId uint `json:"user_id"`
Username string `json:"username"`
NickName string `json:"nick_name"`
SocketId string `json:"socket_id"`
LastActiveTime time.Time `json:"last_active_time"`
Status string `json:"status"`
}
err = db.Order("ou.last_active_time DESC").
Limit(limit).
Offset(offset).
Scan(&results).Error
if err != nil {
return nil, 0, err
}
var onlineUsers []response.OnlineUserResponse
for _, result := range results {
// 计算在线时长
onlineDuration := time.Since(result.LastActiveTime)
onlineUsers = append(onlineUsers, response.OnlineUserResponse{
UserId: result.UserId,
Username: result.Username,
NickName: result.NickName,
SocketId: result.SocketId,
LastActiveTime: result.LastActiveTime,
Status: result.Status,
OnlineDuration: int64(onlineDuration.Minutes()), // 在线时长(分钟)
})
}
return onlineUsers, total, nil
}
// GetOnlineUserByUserId 根据用户ID获取在线用户信息
func (s *OnlineUserService) GetOnlineUserByUserId(userId uint) (model.OnlineUser, error) {
var onlineUser model.OnlineUser
err := global.GVA_DB.Where("user_id = ? AND status = 'online'", userId).First(&onlineUser).Error
return onlineUser, err
}
// GetOnlineUserBySocketId 根据SocketId获取在线用户信息
func (s *OnlineUserService) GetOnlineUserBySocketId(socketId string) (model.OnlineUser, error) {
var onlineUser model.OnlineUser
err := global.GVA_DB.Where("socket_id = ? AND status = 'online'", socketId).First(&onlineUser).Error
return onlineUser, err
}
// GetUserSocketId 获取用户的SocketId
func (s *OnlineUserService) GetUserSocketId(userId uint) (string, error) {
var onlineUser model.OnlineUser
err := global.GVA_DB.Select("socket_id").Where("user_id = ? AND status = 'online'", userId).First(&onlineUser).Error
if err != nil {
return "", err
}
return onlineUser.SocketId, nil
}
// GetUsersByRoleIds 根据角色ID获取在线用户列表
func (s *OnlineUserService) GetUsersByRoleIds(roleIds []uint) ([]uint, error) {
var userIds []uint
err := global.GVA_DB.Table("notice_online_users ou").
Select("DISTINCT ou.user_id").
Joins("JOIN sys_user_authority sua ON ou.user_id = sua.sys_user_id").
Where("sua.sys_authority_id IN ? AND ou.status = 'online'", roleIds).
Pluck("user_id", &userIds).Error
return userIds, err
}
// IsUserOnline 检查用户是否在线
func (s *OnlineUserService) IsUserOnline(userId uint) bool {
var count int64
global.GVA_DB.Model(&model.OnlineUser{}).Where("user_id = ? AND status = 'online'", userId).Count(&count)
return count > 0
}
// GetOnlineUserCount 获取在线用户数量
func (s *OnlineUserService) GetOnlineUserCount() (int64, error) {
var count int64
err := global.GVA_DB.Model(&model.OnlineUser{}).Where("status = 'online'").Count(&count).Error
return count, err
}
// CleanOfflineUsers 清理离线用户(超过指定时间未活跃的用户)
func (s *OnlineUserService) CleanOfflineUsers(timeout time.Duration) error {
cutoffTime := time.Now().Add(-timeout)
// 使用硬删除确保用户立即从在线列表中移除
return global.GVA_DB.Unscoped().Where("last_active_time < ?", cutoffTime).Delete(&model.OnlineUser{}).Error
}
// GetOnlineUserStats 获取在线用户统计数据
func (s *OnlineUserService) GetOnlineUserStats() (response.OnlineUserStatsResponse, error) {
var stats response.OnlineUserStatsResponse
// 获取当前在线总数
err := global.GVA_DB.Model(&model.OnlineUser{}).Where("status = 'online'").Count(&stats.TotalOnline).Error
if err != nil {
return stats, err
}
// 获取今日登录人数(基于最后活跃时间在今天的用户)
today := time.Now().Format("2006-01-02")
todayStart := today + " 00:00:00"
todayEnd := today + " 23:59:59"
err = global.GVA_DB.Model(&model.OnlineUser{}).
Where("last_active_time >= ? AND last_active_time <= ?", todayStart, todayEnd).
Count(&stats.TodayLogin).Error
if err != nil {
return stats, err
}
// 峰值在线人数(这里简化处理,可以根据实际需求存储历史峰值数据)
// 暂时使用当前在线数作为峰值
stats.PeakOnline = stats.TotalOnline
// 平均在线人数(这里简化处理,实际应该基于历史数据计算)
// 暂时使用当前在线数的80%作为平均值
stats.AverageOnline = int64(float64(stats.TotalOnline) * 0.8)
return stats, nil
}
@@ -0,0 +1,268 @@
package service
import (
"log"
"sync"
"github.com/doquangtan/socketio/v4"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model"
"github.com/flipped-aurora/gin-vue-admin/server/plugin/notice/model/response"
)
type SocketService struct {
server *socketio.Socket
connections map[string]*socketio.Socket // socketId -> socket
userSockets map[uint]string // userId -> socketId
mutex sync.RWMutex
}
// SetServer 设置Socket.IO服务器实例
func (s *SocketService) SetServer(server *socketio.Socket) {
s.server = server
}
// AddConnection 添加连接
func (s *SocketService) AddConnection(userId uint, socketId string, socket *socketio.Socket) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.connections == nil {
s.connections = make(map[string]*socketio.Socket)
}
if s.userSockets == nil {
s.userSockets = make(map[uint]string)
}
// 如果用户已有连接,先断开旧连接
if oldSocketId, exists := s.userSockets[userId]; exists {
if oldSocket, ok := s.connections[oldSocketId]; ok {
oldSocket.Disconnect()
delete(s.connections, oldSocketId)
}
}
s.connections[socketId] = socket
s.userSockets[userId] = socketId
log.Printf("用户 %d 连接成功,SocketId: %s", userId, socketId)
}
// RemoveConnection 移除连接
func (s *SocketService) RemoveConnection(socketId string) {
s.mutex.Lock()
defer s.mutex.Unlock()
delete(s.connections, socketId)
// 从用户映射中移除
for userId, sid := range s.userSockets {
if sid == socketId {
delete(s.userSockets, userId)
log.Printf("用户 %d 断开连接,SocketId: %s", userId, socketId)
break
}
}
}
// RemoveConnectionByUserId 根据用户ID移除连接
func (s *SocketService) RemoveConnectionByUserId(userId uint) {
s.mutex.Lock()
defer s.mutex.Unlock()
if socketId, exists := s.userSockets[userId]; exists {
if socket, ok := s.connections[socketId]; ok {
socket.Disconnect()
delete(s.connections, socketId)
}
delete(s.userSockets, userId)
log.Printf("用户 %d 连接被移除", userId)
}
}
// GetConnection 获取连接
func (s *SocketService) GetConnection(socketId string) (*socketio.Socket, bool) {
s.mutex.RLock()
defer s.mutex.RUnlock()
socket, exists := s.connections[socketId]
return socket, exists
}
// GetUserConnection 根据用户ID获取连接
func (s *SocketService) GetUserConnection(userId uint) (*socketio.Socket, bool) {
s.mutex.RLock()
defer s.mutex.RUnlock()
if socketId, exists := s.userSockets[userId]; exists {
if socket, ok := s.connections[socketId]; ok {
return socket, true
}
}
return nil, false
}
// GetUserSocketId 获取用户的SocketId
func (s *SocketService) GetUserSocketId(userId uint) (string, bool) {
s.mutex.RLock()
defer s.mutex.RUnlock()
socketId, exists := s.userSockets[userId]
return socketId, exists
}
// IsUserOnline 检查用户是否在线
func (s *SocketService) IsUserOnline(userId uint) bool {
s.mutex.RLock()
defer s.mutex.RUnlock()
_, exists := s.userSockets[userId]
return exists
}
// GetOnlineUsers 获取在线用户列表
func (s *SocketService) GetOnlineUsers() []uint {
s.mutex.RLock()
defer s.mutex.RUnlock()
users := make([]uint, 0, len(s.userSockets))
for userId := range s.userSockets {
users = append(users, userId)
}
return users
}
// GetOnlineUserCount 获取在线用户数量
func (s *SocketService) GetOnlineUserCount() int {
s.mutex.RLock()
defer s.mutex.RUnlock()
return len(s.userSockets)
}
// SendToUser 向指定用户发送消息
func (s *SocketService) SendToUser(userId uint, event string, data interface{}) error {
log.Printf("尝试向用户 %d 发送消息,事件: %s", userId, event)
socket, exists := s.GetUserConnection(userId)
if !exists {
log.Printf("用户 %d 不在线,无法发送消息", userId)
return nil // 用户不在线,不发送
}
log.Printf("用户 %d 在线,发送消息,事件: %s", userId, event)
socket.Emit(event, data)
log.Printf("消息已发送给用户 %d,事件: %s", userId, event)
return nil
}
// SendToUsers 向多个用户发送消息
func (s *SocketService) SendToUsers(userIds []uint, event string, data interface{}) {
for _, userId := range userIds {
if err := s.SendToUser(userId, event, data); err != nil {
log.Printf("向用户 %d 发送消息失败: %v", userId, err)
}
}
}
// BroadcastToAll 向所有在线用户广播消息
func (s *SocketService) BroadcastToAll(event string, data interface{}) {
s.mutex.RLock()
defer s.mutex.RUnlock()
log.Printf("开始广播消息,事件: %s,在线用户数: %d", event, len(s.userSockets))
log.Printf("当前在线用户列表: %v", s.userSockets)
for userId := range s.userSockets {
log.Printf("向用户 %d 发送消息,事件: %s", userId, event)
if err := s.SendToUser(userId, event, data); err != nil {
log.Printf("向用户 %d 广播消息失败: %v", userId, err)
} else {
log.Printf("向用户 %d 广播消息成功", userId)
}
}
}
// SendNotificationToUser 向用户发送通知
func (s *SocketService) SendNotificationToUser(userId uint, notification *model.Notification) error {
notificationData := response.NotificationResponse{
ID: notification.ID,
Title: notification.Title,
Content: notification.Content,
Type: notification.Type,
Priority: notification.Priority,
SenderId: notification.SenderId,
TargetType: notification.TargetType,
Status: notification.Status,
PublishTime: notification.PublishTime,
ExpireTime: notification.ExpireTime,
CreatedAt: notification.CreatedAt,
UpdatedAt: notification.UpdatedAt,
}
return s.SendToUser(userId, "new_notification", notificationData)
}
// SendNotificationToUsers 向多个用户发送通知
func (s *SocketService) SendNotificationToUsers(userIds []uint, notification *model.Notification) {
notificationData := response.NotificationResponse{
ID: notification.ID,
Title: notification.Title,
Content: notification.Content,
Type: notification.Type,
Priority: notification.Priority,
SenderId: notification.SenderId,
TargetType: notification.TargetType,
Status: notification.Status,
PublishTime: notification.PublishTime,
ExpireTime: notification.ExpireTime,
CreatedAt: notification.CreatedAt,
UpdatedAt: notification.UpdatedAt,
}
for _, userId := range userIds {
if err := s.SendToUser(userId, "new_notification", notificationData); err != nil {
log.Printf("向用户 %d 发送通知失败: %v", userId, err)
}
}
}
// BroadcastNotification 广播通知给所有在线用户
func (s *SocketService) BroadcastNotification(notification *model.Notification) {
notificationData := response.NotificationResponse{
ID: notification.ID,
Title: notification.Title,
Content: notification.Content,
Type: notification.Type,
Priority: notification.Priority,
SenderId: notification.SenderId,
TargetType: notification.TargetType,
Status: notification.Status,
PublishTime: notification.PublishTime,
ExpireTime: notification.ExpireTime,
CreatedAt: notification.CreatedAt,
UpdatedAt: notification.UpdatedAt,
}
s.BroadcastToAll("new_notification", notificationData)
}
// SendReadStatusUpdate 发送已读状态更新
func (s *SocketService) SendReadStatusUpdate(userId uint, notificationId uint) error {
data := map[string]interface{}{
"notification_id": notificationId,
"read_status": true,
}
return s.SendToUser(userId, "read_status_update", data)
}
// GetConnectionInfo 获取连接信息
func (s *SocketService) GetConnectionInfo() map[string]interface{} {
s.mutex.RLock()
defer s.mutex.RUnlock()
return map[string]interface{}{
"total_connections": len(s.connections),
"online_users": len(s.userSockets),
"user_sockets": s.userSockets,
}
}
+8 -2
View File
@@ -2,6 +2,8 @@ package system
import (
"errors"
"github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
"github.com/gin-gonic/gin"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
@@ -105,8 +107,12 @@ func (dictionaryService *DictionaryService) GetSysDictionary(Type string, Id uin
//@param: info request.SysDictionarySearch
//@return: err error, list interface{}, total int64
func (dictionaryService *DictionaryService) GetSysDictionaryInfoList() (list interface{}, err error) {
func (dictionaryService *DictionaryService) GetSysDictionaryInfoList(c *gin.Context, req request.SysDictionarySearch) (list interface{}, err error) {
var sysDictionarys []system.SysDictionary
err = global.GVA_DB.Find(&sysDictionarys).Error
query := global.GVA_DB.WithContext(c)
if req.Name != "" {
query = query.Where("name LIKE ? OR type LIKE ?", "%"+req.Name+"%", "%"+req.Name+"%")
}
err = query.Find(&sysDictionarys).Error
return sysDictionarys, err
}
+1 -1
View File
@@ -4,7 +4,7 @@ VITE_SERVER_PORT = 8888
VITE_BASE_API = /api
VITE_FILE_API = /api
VITE_BASE_PATH = http://127.0.0.1
VITE_POSITION = close
VITE_POSITION = open
VITE_EDITOR = code
// VITE_EDITOR = webstorm 如果使用webstorm开发且要使用dom定位到代码行功能 请先自定添加 webstorm到环境变量 再将VITE_EDITOR值修改为webstorm
// 如果使用docker-compose开发模式,设置为下面的地址或本机主机IP
+1
View File
@@ -39,6 +39,7 @@
"pinia": "^2.2.2",
"qs": "^6.13.0",
"screenfull": "^6.0.2",
"socket.io-client": "^4.8.1",
"sortablejs": "^1.15.3",
"spark-md5": "^3.0.2",
"universal-cookie": "^7",
+29 -26
View File
@@ -1,5 +1,8 @@
<template>
<div id="app" class="bg-gray-50 text-slate-700 dark:text-slate-500 dark:bg-slate-800">
<div
id="app"
class="bg-gray-50 text-slate-700 !dark:text-slate-500 dark:bg-slate-800"
>
<el-config-provider :locale="zhCn">
<router-view />
<Application />
@@ -8,36 +11,36 @@
</template>
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import Application from '@/components/application/index.vue'
import { useAppStore } from '@/pinia'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import Application from '@/components/application/index.vue'
import { useAppStore } from '@/pinia'
useAppStore()
defineOptions({
name: 'App'
})
useAppStore()
defineOptions({
name: 'App'
})
</script>
<style lang="scss">
// 引入初始化样式
#app {
height: 100vh;
overflow: hidden;
font-weight: 400 !important;
}
// 引入初始化样式
#app {
height: 100vh;
overflow: hidden;
font-weight: 400 !important;
}
.el-button {
font-weight: 400 !important;
}
.el-button {
font-weight: 400 !important;
}
.gva-body-h {
min-height: calc(100% - 3rem);
}
.gva-body-h {
min-height: calc(100% - 3rem);
}
.gva-container {
height: calc(100% - 2.5rem);
}
.gva-container {
height: calc(100% - 2.5rem);
}
.gva-container2 {
height: calc(100% - 4.5rem);
}
.gva-container2 {
height: calc(100% - 4.5rem);
}
</style>
+160
View File
@@ -0,0 +1,160 @@
import service from '@/utils/request'
// @Summary 创建通知
// @Produce application/json
// @Param data body {title:"string",content:"string",type:"string",targetType:"string",targetIds:"array"}
// @Router /notice/notification/createNotification [post]
export const createNotification = (data) => {
return service({
url: '/notice/notification/createNotification',
method: 'post',
data: data
})
}
// @Summary 获取通知列表
// @Produce application/json
// @Param data body {page:"int",pageSize:"int",title:"string",type:"string",status:"string"}
// @Router /notice/notification/getNotificationList [post]
export const getNotificationList = (data) => {
return service({
url: '/notice/notification/getNotificationList',
method: 'post',
data: data
})
}
// @Summary 获取通知详情
// @Produce application/json
// @Param id path int true "通知ID"
// @Router /notice/notification/getNotificationById/{id} [get]
export const getNotificationById = (id) => {
return service({
url: `/notice/notification/getNotificationById/${id}`,
method: 'get'
})
}
// @Summary 更新通知
// @Produce application/json
// @Param data body {id:"int",title:"string",content:"string",type:"string",targetType:"string",targetIds:"array"}
// @Router /notice/notification/updateNotification [put]
export const updateNotification = (data) => {
return service({
url: '/notice/notification/updateNotification',
method: 'put',
data: data
})
}
// @Summary 删除通知
// @Produce application/json
// @Param id path int true "通知ID"
// @Router /notice/notification/deleteNotification/{id} [delete]
export const deleteNotification = (id) => {
return service({
url: `/notice/notification/deleteNotification/${id}`,
method: 'delete'
})
}
// @Summary 发送通知
// @Produce application/json
// @Param data body {id:"int"}
// @Router /notice/notification/sendNotification [post]
export const sendNotification = (data) => {
return service({
url: '/notice/notification/sendNotification',
method: 'post',
data: data
})
}
// @Summary 发布通知
// @Produce application/json
// @Param id path int true "通知ID"
// @Router /notice/notification/publishNotification/{id} [post]
export const publishNotification = (id) => {
return service({
url: `/notice/notification/publishNotification/${id}`,
method: 'post'
})
}
// @Summary 获取用户通知列表
// @Produce application/json
// @Param data body {page:"int",pageSize:"int",isRead:"bool"}
// @Router /notice/notification/getUserNotifications [post]
export const getUserNotificationList = (data) => {
return service({
url: '/notice/notification/getUserNotifications',
method: 'post',
data: data
})
}
// @Summary 标记通知为已读
// @Produce application/json
// @Param data body {notificationId:"int"}
// @Router /notice/notification/markAsRead [post]
export const markNotificationAsRead = (data) => {
return service({
url: '/notice/notification/markAsRead',
method: 'post',
data: data
})
}
// @Summary 获取未读通知统计
// @Produce application/json
// @Router /notice/notification/getNotificationStats [get]
export const getUnreadNotificationCount = () => {
return service({
url: '/notice/notification/getNotificationStats',
method: 'get'
})
}
// @Summary 获取在线用户列表
// @Produce application/json
// @Param data body {page:"int",pageSize:"int",username:"string",nickName:"string",roleId:"int"}
// @Router /notice/onlineUser/getOnlineUsers [post]
export const getOnlineUserList = (data) => {
return service({
url: '/notice/onlineUser/getOnlineUsers',
method: 'post',
data: data
})
}
// @Summary 删除用户通知记录
// @Produce application/json
// @Param notificationId path int true "通知ID"
// @Router /notice/notification/deleteUserNotification/{notificationId} [delete]
export const deleteUserNotification = (notificationId) => {
return service({
url: `/notice/notification/deleteUserNotification/${notificationId}`,
method: 'delete'
})
}
// @Summary 强制用户下线
// @Produce application/json
// @Param userId path int true "用户ID"
// @Router /notice/onlineUser/removeOnlineUser/{userId} [delete]
export const kickUser = (userId) => {
return service({
url: `/notice/onlineUser/removeOnlineUser/${userId}`,
method: 'delete'
})
}
// @Summary 获取在线用户统计
// @Produce application/json
// @Router /notice/onlineUser/getOnlineUserStats [get]
export const getOnlineUserStats = () => {
return service({
url: '/notice/onlineUser/getOnlineUserStats',
method: 'get'
})
}
+43
View File
@@ -0,0 +1,43 @@
export default {
install: (app) => {
app.directive('click-outside', {
mounted(el, binding) {
const handler = (e) => {
// 如果绑定的元素包含事件目标,或元素已经被移除,则不触发
if (!el || el.contains(e.target) || e.target === el) return
// 支持函数或对象 { handler: fn, exclude: [el1, el2], capture: true }
const value = binding.value
if (value && typeof value === 'object') {
if (
value.exclude &&
value.exclude.some(
(ex) => ex && ex.contains && ex.contains(e.target)
)
)
return
if (typeof value.handler === 'function') value.handler(e)
} else if (typeof value === 'function') {
value(e)
}
}
// 存到 el 上,便于解绑
el.__clickOutsideHandler__ = handler
// 延迟注册,避免 mounted 时触发(比如当点击就是触发绑定动作时)
setTimeout(() => {
document.addEventListener('mousedown', handler)
document.addEventListener('touchstart', handler)
}, 0)
},
unmounted(el) {
const h = el.__clickOutsideHandler__
if (h) {
document.removeEventListener('mousedown', h)
document.removeEventListener('touchstart', h)
delete el.__clickOutsideHandler__
}
}
})
}
}
+10 -2
View File
@@ -1,6 +1,6 @@
import './style/element_visiable.scss'
import 'element-plus/theme-chalk/dark/css-vars.css'
import 'uno.css';
import 'uno.css'
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
@@ -12,11 +12,19 @@ import router from '@/router/index'
import '@/permission'
import run from '@/core/gin-vue-admin.js'
import auth from '@/directive/auth'
import clickOutSide from '@/directive/clickOutSide'
import { store } from '@/pinia'
import App from './App.vue'
const app = createApp(App)
app.config.productionTip = false
app.use(run).use(ElementPlus).use(store).use(auth).use(router).mount('#app')
app
.use(run)
.use(ElementPlus)
.use(store)
.use(auth)
.use(clickOutSide)
.use(router)
.mount('#app')
export default app
+5
View File
@@ -43,6 +43,10 @@
"/src/view/layout/setting/modules/layout/index.vue": "LayoutSettings",
"/src/view/layout/tabs/index.vue": "HistoryComponent",
"/src/view/login/index.vue": "Login",
"/src/view/notice/index.vue": "Index",
"/src/view/notice/notification/index.vue": "NotificationManagement",
"/src/view/notice/onlineUser/index.vue": "OnlineUserManagement",
"/src/view/notice/userCenter/index.vue": "UserNotificationCenter",
"/src/view/person/person.vue": "Person",
"/src/view/routerHolder.vue": "RouterHolder",
"/src/view/superAdmin/api/api.vue": "Api",
@@ -75,6 +79,7 @@
"/src/view/systemTools/pubPlug/pubPlug.vue": "PubPlug",
"/src/view/systemTools/system/system.vue": "Config",
"/src/view/systemTools/version/version.vue": "SysVersion",
"/src/view/userCenter/index.vue": "UserNotificationCenter",
"/src/plugin/announcement/form/info.vue": "InfoForm",
"/src/plugin/announcement/view/info.vue": "Info",
"/src/plugin/email/view/index.vue": "Email"
+260
View File
@@ -0,0 +1,260 @@
import { io } from 'socket.io-client'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { ElNotification } from 'element-plus'
import { useRouter } from 'vue-router'
const router = useRouter()
class SocketManager {
constructor() {
this.socket = null
this.connected = false
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectInterval = 3000
this.heartbeatInterval = null
this.callbacks = new Map()
}
// 连接Socket.IO服务器
connect() {
if (this.socket && this.connected) {
return Promise.resolve()
}
const userStore = useUserStore()
const token = userStore.token
if (!token) {
console.warn('No token found, cannot connect to socket')
return Promise.reject(new Error('No token'))
}
return new Promise((resolve, reject) => {
this.socket = io('http://127.0.0.1:8888/', {
auth: {
token: token
},
transports: ['websocket', 'polling'],
timeout: 10000,
forceNew: true
})
// 连接成功
this.socket.on('connect', () => {
console.log('Socket connected:', this.socket.id)
this.connected = true
this.reconnectAttempts = 0
this.startHeartbeat()
resolve()
})
// 连接失败
this.socket.on('connect_error', (error) => {
console.error('Socket connection error:', error)
this.connected = false
reject(error)
})
// 断开连接
this.socket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason)
this.connected = false
this.stopHeartbeat()
// 如果是服务器主动断开,尝试重连
if (reason === 'io server disconnect') {
this.reconnect()
}
})
// 认证失败
this.socket.on('auth_error', (error) => {
console.error('Socket auth error:', error)
ElMessage.error('Socket认证失败,请重新登录')
this.disconnect()
})
// 新通知
this.socket.on('new_notification', (data) => {
console.log('Received new notification:', data)
this.emit('new_notification', data)
})
// 通知统计更新
this.socket.on('notification_stats', (data) => {
console.log('Notification stats updated:', data)
this.emit('notification_stats', data)
})
// 用户被踢下线
this.socket.on('forceLogout', (data) => {
console.log('forceLogout:', data)
this.emit('forceLogout', data)
})
})
}
// 断开连接
disconnect() {
if (this.socket) {
this.stopHeartbeat()
this.socket.disconnect()
this.socket = null
this.connected = false
}
}
// 重连
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached')
return
}
this.reconnectAttempts++
console.log(
`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`
)
setTimeout(() => {
this.connect().catch(() => {
this.reconnect()
})
}, this.reconnectInterval)
}
// 开始心跳
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.socket && this.connected) {
this.socket.emit('heartbeat', { timestamp: Date.now() })
}
}, 30000) // 30秒心跳
}
// 停止心跳
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
// 加入房间
joinRoom(roomId) {
if (this.socket && this.connected) {
this.socket.emit('join_room', { roomId })
}
}
// 离开房间
leaveRoom(roomId) {
if (this.socket && this.connected) {
this.socket.emit('leave_room', { roomId })
}
}
// 标记通知为已读
markNotificationAsRead(notificationId) {
if (this.socket && this.connected) {
this.socket.emit('notification_read', { notificationId })
}
}
// 获取通知统计
getNotificationStats() {
if (this.socket && this.connected) {
this.socket.emit('get_notification_stats')
}
}
// 监听事件
on(event, callback) {
if (!this.callbacks.has(event)) {
this.callbacks.set(event, [])
}
this.callbacks.get(event).push(callback)
}
// 移除事件监听
off(event, callback) {
if (this.callbacks.has(event)) {
const callbacks = this.callbacks.get(event)
const index = callbacks.indexOf(callback)
if (index > -1) {
callbacks.splice(index, 1)
}
}
}
// 触发事件
emit(event, data) {
if (this.callbacks.has(event)) {
this.callbacks.get(event).forEach((callback) => {
try {
callback(data)
} catch (error) {
console.error('Error in socket callback:', error)
}
})
}
}
// 获取连接状态
isConnected() {
return this.connected
}
// 获取Socket ID
getSocketId() {
return this.socket ? this.socket.id : null
}
}
// 创建全局Socket管理器实例
export const socketManager = new SocketManager()
export const initSocketConnection = (userStore) => {
if (userStore.token && userStore.userInfo.ID) {
// 监听新通知事件
socketManager.on('new_notification', (notification) => {
ElNotification({
title: '新通知',
message: notification.title,
type: 'info',
duration: 5000,
onClick: () => {
// 点击通知跳转到通知详情或通知列表
router.push({ name: 'NotificationManagement' })
}
})
})
// 监听通知统计更新
socketManager.on('notificationStatsUpdate', (stats) => {
// 可以在这里更新全局的未读通知数量
console.log('通知统计更新:', stats)
})
// 监听被踢下线事件
socketManager.on('forceLogout', (data) => {
console.log('收到被踢下线通知:', data)
ElNotification({
title: '系统提示',
message: data.message || '您已被管理员强制下线',
type: 'warning',
duration: 0
})
// 清除用户信息并跳转到登录页
setTimeout(() => {
userStore.LoginOut()
router.push({ name: 'Login' })
}, 2000)
})
// 连接到服务器
socketManager.connect()
}
}
+319 -265
View File
@@ -1,26 +1,46 @@
<template>
<div v-loading.fullscreen.lock="fullscreenLoading">
<div class="flex gap-4 p-2">
<div class="flex-none w-64 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded p-4">
<div class="flex gap-4 pt-2">
<div
class="flex-none w-64 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded p-4"
>
<el-scrollbar style="height: calc(100vh - 300px)">
<el-tree
:data="categories"
node-key="id"
:props="defaultProps"
@node-click="handleNodeClick"
default-expand-all
:data="categories"
node-key="id"
:props="defaultProps"
@node-click="handleNodeClick"
default-expand-all
>
<template #default="{ node, data }">
<div class="w-36" :class="search.classId === data.ID ? 'text-blue-500 font-bold' : ''">{{ data.name }}
<div
class="w-36"
:class="
search.classId === data.ID ? 'text-blue-500 font-bold' : ''
"
>
{{ data.name }}
</div>
<el-dropdown>
<el-icon class="ml-3 text-right" v-if="data.ID > 0"><MoreFilled /></el-icon>
<el-icon class="ml-3 text-right" v-if="data.ID > 0"
><MoreFilled
/></el-icon>
<el-icon class="ml-3 text-right mt-1" v-else><Plus /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="addCategoryFun(data)">添加分类</el-dropdown-item>
<el-dropdown-item @click="editCategory(data)" v-if="data.ID > 0">编辑分类</el-dropdown-item>
<el-dropdown-item @click="deleteCategoryFun(data.ID)" v-if="data.ID > 0">删除分类</el-dropdown-item>
<el-dropdown-item @click="addCategoryFun(data)"
>添加分类</el-dropdown-item
>
<el-dropdown-item
@click="editCategory(data)"
v-if="data.ID > 0"
>编辑分类</el-dropdown-item
>
<el-dropdown-item
@click="deleteCategoryFun(data.ID)"
v-if="data.ID > 0"
>删除分类</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -28,98 +48,118 @@
</el-tree>
</el-scrollbar>
</div>
<div class="flex-1 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900">
<div
class="flex-1 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900"
>
<div class="gva-table-box mt-0 mb-0">
<warning-bar title="点击“文件名”可以编辑;选择的类别即是上传的类别。" />
<warning-bar
title="点击“文件名”可以编辑;选择的类别即是上传的类别。"
/>
<div class="gva-btn-list gap-3">
<upload-common :image-common="imageCommon" :classId="search.classId" @on-success="onSuccess" />
<upload-common
:image-common="imageCommon"
:classId="search.classId"
@on-success="onSuccess"
/>
<cropper-image :classId="search.classId" @on-success="onSuccess" />
<QRCodeUpload :classId="search.classId" @on-success="onSuccess" />
<upload-image
:image-url="imageUrl"
:file-size="512"
:max-w-h="1080"
:classId="search.classId"
@on-success="onSuccess"
:image-url="imageUrl"
:file-size="512"
:max-w-h="1080"
:classId="search.classId"
@on-success="onSuccess"
/>
<el-button type="primary" icon="upload" @click="importUrlFunc">
导入URL
</el-button>
<el-input
v-model="search.keyword"
class="w-72"
placeholder="请输入文件名或备注"
v-model="search.keyword"
class="w-72"
placeholder="请输入文件名或备注"
/>
<el-button type="primary" icon="search" @click="onSubmit"
>查询
</el-button
>
>查询
</el-button>
</div>
<el-table :data="tableData">
<el-table-column align="left" label="预览" width="100">
<template #default="scope">
<CustomPic pic-type="file" :pic-src="scope.row.url" preview/>
<CustomPic pic-type="file" :pic-src="scope.row.url" preview />
</template>
</el-table-column>
<el-table-column align="left" label="日期" prop="UpdatedAt" width="180">
<el-table-column
align="left"
label="日期"
prop="UpdatedAt"
width="180"
>
<template #default="scope">
<div>{{ formatDate(scope.row.UpdatedAt) }}</div>
</template>
</el-table-column>
<el-table-column
align="left"
label="文件名/备注"
prop="name"
width="180"
align="left"
label="文件名/备注"
prop="name"
width="180"
>
<template #default="scope">
<div class="cursor-pointer" @click="editFileNameFunc(scope.row)">
<div
class="cursor-pointer"
@click="editFileNameFunc(scope.row)"
>
{{ scope.row.name }}
</div>
</template>
</el-table-column>
<el-table-column align="left" label="链接" prop="url" min-width="300"/>
<el-table-column
align="left"
label="链接"
prop="url"
min-width="300"
/>
<el-table-column align="left" label="标签" prop="tag" width="100">
<template #default="scope">
<el-tag
:type="scope.row.tag?.toLowerCase() === 'jpg' ? 'info' : 'success'"
disable-transitions
>{{ scope.row.tag }}
:type="
scope.row.tag?.toLowerCase() === 'jpg' ? 'info' : 'success'
"
disable-transitions
>{{ scope.row.tag }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="操作" width="160">
<template #default="scope">
<el-button
icon="download"
type="primary"
link
@click="downloadFile(scope.row)"
>下载
</el-button
>
icon="download"
type="primary"
link
@click="downloadFile(scope.row)"
>下载
</el-button>
<el-button
icon="delete"
type="primary"
link
@click="deleteFileFunc(scope.row)"
>删除
</el-button
>
icon="delete"
type="primary"
link
@click="deleteFileFunc(scope.row)"
>删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:style="{ float: 'right', padding: '20px' }"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:style="{ float: 'right', padding: '20px' }"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
@@ -127,23 +167,34 @@
</div>
<!-- 添加分类弹窗 -->
<el-dialog v-model="categoryDialogVisible" @close="closeAddCategoryDialog" width="520"
:title="(categoryFormData.ID === 0 ? '添加' : '编辑') + '分类'"
draggable
<el-dialog
v-model="categoryDialogVisible"
@close="closeAddCategoryDialog"
width="520"
:title="(categoryFormData.ID === 0 ? '添加' : '编辑') + '分类'"
draggable
>
<el-form ref="categoryForm" :rules="rules" :model="categoryFormData" label-width="80px">
<el-form
ref="categoryForm"
:rules="rules"
:model="categoryFormData"
label-width="80px"
>
<el-form-item label="上级分类">
<el-tree-select
v-model="categoryFormData.pid"
:data="categories"
check-strictly
:props="defaultProps"
:render-after-expand="false"
style="width: 240px"
v-model="categoryFormData.pid"
:data="categories"
check-strictly
:props="defaultProps"
:render-after-expand="false"
style="width: 240px"
/>
</el-form-item>
<el-form-item label="分类名称" prop="name">
<el-input v-model.trim="categoryFormData.name" placeholder="分类名称"></el-input>
<el-input
v-model.trim="categoryFormData.name"
placeholder="分类名称"
></el-input>
</el-form-item>
</el-form>
<template #footer>
@@ -151,88 +202,91 @@
<el-button type="primary" @click="confirmAddCategory">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {
getFileList,
deleteFile,
editFileName,
importURL
} from '@/api/fileUploadAndDownload'
import {downloadImage} from '@/utils/downloadImg'
import CustomPic from '@/components/customPic/index.vue'
import UploadImage from '@/components/upload/image.vue'
import UploadCommon from '@/components/upload/common.vue'
import {CreateUUID, formatDate} from '@/utils/format'
import WarningBar from '@/components/warningBar/warningBar.vue'
import {
getFileList,
deleteFile,
editFileName,
importURL
} from '@/api/fileUploadAndDownload'
import { downloadImage } from '@/utils/downloadImg'
import CustomPic from '@/components/customPic/index.vue'
import UploadImage from '@/components/upload/image.vue'
import UploadCommon from '@/components/upload/common.vue'
import { CreateUUID, formatDate } from '@/utils/format'
import WarningBar from '@/components/warningBar/warningBar.vue'
import {ref} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {addCategory, deleteCategory, getCategoryList} from "@/api/attachmentCategory";
import CropperImage from "@/components/upload/cropper.vue";
import QRCodeUpload from "@/components/upload/QR-code.vue";
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
addCategory,
deleteCategory,
getCategoryList
} from '@/api/attachmentCategory'
import CropperImage from '@/components/upload/cropper.vue'
import QRCodeUpload from '@/components/upload/QR-code.vue'
defineOptions({
name: 'Upload'
})
const fullscreenLoading = ref(false)
const path = ref(import.meta.env.VITE_BASE_API)
const imageUrl = ref('')
const imageCommon = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const search = ref({
keyword: null,
classId: 0
})
const tableData = ref([])
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const onSubmit = () => {
search.value.classId = 0
page.value = 1
getTableData()
}
// 查询
const getTableData = async () => {
const table = await getFileList({
page: page.value,
pageSize: pageSize.value,
...search.value
defineOptions({
name: 'Upload'
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
const fullscreenLoading = ref(false)
const path = ref(import.meta.env.VITE_BASE_API)
const imageUrl = ref('')
const imageCommon = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const search = ref({
keyword: null,
classId: 0
})
const tableData = ref([])
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
}
getTableData()
const deleteFileFunc = async (row) => {
ElMessageBox.confirm('此操作将永久删除文件, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const onSubmit = () => {
search.value.classId = 0
page.value = 1
getTableData()
}
// 查询
const getTableData = async () => {
const table = await getFileList({
page: page.value,
pageSize: pageSize.value,
...search.value
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
getTableData()
const deleteFileFunc = async (row) => {
ElMessageBox.confirm('此操作将永久删除文件, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await deleteFile(row)
if (res.code === 0) {
@@ -252,30 +306,30 @@ const deleteFileFunc = async (row) => {
message: '已取消删除'
})
})
}
const downloadFile = (row) => {
if (row.url.indexOf('http://') > -1 || row.url.indexOf('https://') > -1) {
downloadImage(row.url, row.name)
} else {
downloadImage(path.value + '/' + row.url, row.name)
}
}
/**
* 编辑文件名或者备注
* @param row
* @returns {Promise<void>}
*/
const editFileNameFunc = async (row) => {
ElMessageBox.prompt('请输入文件名或者备注', '编辑', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\S/,
inputErrorMessage: '不能为空',
inputValue: row.name
})
.then(async ({value}) => {
const downloadFile = (row) => {
if (row.url.indexOf('http://') > -1 || row.url.indexOf('https://') > -1) {
downloadImage(row.url, row.name)
} else {
downloadImage(path.value + '/' + row.url, row.name)
}
}
/**
* 编辑文件名或者备注
* @param row
* @returns {Promise<void>}
*/
const editFileNameFunc = async (row) => {
ElMessageBox.prompt('请输入文件名或者备注', '编辑', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\S/,
inputErrorMessage: '不能为空',
inputValue: row.name
})
.then(async ({ value }) => {
row.name = value
// console.log(row)
const res = await editFileName(row)
@@ -293,22 +347,22 @@ const editFileNameFunc = async (row) => {
message: '取消修改'
})
})
}
}
/**
* 导入URL
*/
const importUrlFunc = () => {
ElMessageBox.prompt('格式:文件名|链接或者仅链接。', '导入', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder:
/**
* 导入URL
*/
const importUrlFunc = () => {
ElMessageBox.prompt('格式:文件名|链接或者仅链接。', '导入', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder:
'我的图片|https://my-oss.com/my.png\nhttps://my-oss.com/my_1.png',
inputPattern: /\S/,
inputErrorMessage: '不能为空'
})
.then(async ({value}) => {
inputPattern: /\S/,
inputErrorMessage: '不能为空'
})
.then(async ({ value }) => {
let data = value.split('\n')
let importData = []
data.forEach((item) => {
@@ -348,101 +402,101 @@ const importUrlFunc = () => {
message: '取消导入'
})
})
}
const onSuccess = () => {
search.value.keyword = null
page.value = 1
getTableData()
}
const defaultProps = {
children: 'children',
label: 'name',
value: 'ID'
}
const categories = ref([])
const fetchCategories = async () => {
const res = await getCategoryList()
let data = {
name: '全部分类',
ID: 0,
pid: 0,
children:[]
}
if (res.code === 0) {
categories.value = res.data || []
categories.value.unshift(data)
const onSuccess = () => {
search.value.keyword = null
page.value = 1
getTableData()
}
}
const handleNodeClick = (node) => {
search.value.keyword = null
search.value.classId = node.ID
page.value = 1
getTableData()
}
const categoryDialogVisible = ref(false)
const categoryFormData = ref({
ID: 0,
pid: 0,
name: ''
})
const categoryForm = ref(null)
const rules = ref({
name: [
{required: true, message: '请输入分类名称', trigger: 'blur'},
{max: 20, message: '最多20位字符', trigger: 'blur'}
]
})
const addCategoryFun = (category) => {
categoryDialogVisible.value = true
categoryFormData.value.ID = 0
categoryFormData.value.pid = category.ID
}
const editCategory = (category) => {
categoryFormData.value = {
ID: category.ID,
pid: category.pid,
name: category.name
const defaultProps = {
children: 'children',
label: 'name',
value: 'ID'
}
categoryDialogVisible.value = true
}
const deleteCategoryFun = async (id) => {
const res = await deleteCategory({id: id})
if (res.code === 0) {
ElMessage.success({type: 'success', message: '删除成功'})
await fetchCategories()
}
}
const confirmAddCategory = async () => {
categoryForm.value.validate(async valid => {
if (valid) {
const res = await addCategory(categoryFormData.value)
if (res.code === 0) {
ElMessage({type: 'success', message: '操作成功'})
await fetchCategories()
closeAddCategoryDialog()
}
const categories = ref([])
const fetchCategories = async () => {
const res = await getCategoryList()
let data = {
name: '全部分类',
ID: 0,
pid: 0,
children: []
}
})
}
if (res.code === 0) {
categories.value = res.data || []
categories.value.unshift(data)
}
}
const closeAddCategoryDialog = () => {
categoryDialogVisible.value = false
categoryFormData.value = {
const handleNodeClick = (node) => {
search.value.keyword = null
search.value.classId = node.ID
page.value = 1
getTableData()
}
const categoryDialogVisible = ref(false)
const categoryFormData = ref({
ID: 0,
pid: 0,
name: ''
}
}
})
fetchCategories()
const categoryForm = ref(null)
const rules = ref({
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 20, message: '最多20位字符', trigger: 'blur' }
]
})
const addCategoryFun = (category) => {
categoryDialogVisible.value = true
categoryFormData.value.ID = 0
categoryFormData.value.pid = category.ID
}
const editCategory = (category) => {
categoryFormData.value = {
ID: category.ID,
pid: category.pid,
name: category.name
}
categoryDialogVisible.value = true
}
const deleteCategoryFun = async (id) => {
const res = await deleteCategory({ id: id })
if (res.code === 0) {
ElMessage.success({ type: 'success', message: '删除成功' })
await fetchCategories()
}
}
const confirmAddCategory = async () => {
categoryForm.value.validate(async (valid) => {
if (valid) {
const res = await addCategory(categoryFormData.value)
if (res.code === 0) {
ElMessage({ type: 'success', message: '操作成功' })
await fetchCategories()
closeAddCategoryDialog()
}
}
})
}
const closeAddCategoryDialog = () => {
categoryDialogVisible.value = false
categoryFormData.value = {
ID: 0,
pid: 0,
name: ''
}
}
fetchCategories()
</script>
+28 -5
View File
@@ -14,7 +14,8 @@
<div class="flex flex-row w-full gva-container pt-16 box-border !h-full">
<gva-aside
v-if="
config.side_mode === 'normal' || config.side_mode === 'sidebar' ||
config.side_mode === 'normal' ||
config.side_mode === 'sidebar' ||
(device === 'mobile' && config.side_mode == 'head') ||
(device === 'mobile' && config.side_mode == 'combination')
"
@@ -23,10 +24,10 @@
v-if="config.side_mode === 'combination' && device !== 'mobile'"
mode="normal"
/>
<div class="flex-1 px-2 w-0 h-full">
<div class="flex-1 w-0 h-full">
<gva-tabs v-if="config.showTabs" />
<div
class="overflow-auto"
class="overflow-auto px-2"
:class="config.showTabs ? 'gva-container2' : 'gva-container pt-1'"
>
<router-view v-if="reloadFlag" v-slot="{ Component, route }">
@@ -34,7 +35,10 @@
id="gva-base-load-dom"
class="gva-body-h bg-gray-50 dark:bg-slate-800"
>
<transition mode="out-in" :name="route.meta.transitionType || config.transition_type">
<transition
mode="out-in"
:name="route.meta.transitionType || config.transition_type"
>
<keep-alive :include="routerStore.keepAliveRouters">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
@@ -55,12 +59,21 @@
import GvaTabs from './tabs/index.vue'
import BottomInfo from '@/components/bottomInfo/bottomInfo.vue'
import { emitter } from '@/utils/bus.js'
import { ref, onMounted, nextTick, reactive, watchEffect } from 'vue'
import {
ref,
onMounted,
nextTick,
reactive,
watchEffect,
onUnmounted
} from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router'
import { useUserStore } from '@/pinia/modules/user'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
import { socketManager, initSocketConnection } from '@/utils/socket'
import '@/style/transition.scss'
const appStore = useAppStore()
const { config, isDark, device } = storeToRefs(appStore)
@@ -88,6 +101,16 @@
if (userStore.loadingInstance) {
userStore.loadingInstance.close()
}
// 初始化 Socket.IO 连接
initSocketConnection(userStore)
})
onUnmounted(() => {
// 组件卸载时断开 Socket 连接
if (socketManager) {
socketManager.disconnect()
}
})
const userStore = useUserStore()
+53
View File
@@ -0,0 +1,53 @@
<template>
<div class="notice-center">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>通知中心</span>
</div>
</template>
<div class="notice-content">
<p>欢迎使用通知中心!</p>
<p>这里是通知中心的主页面,您可以通过左侧菜单访问具体功能:</p>
<ul>
<li>通知管理:创建、编辑、发送通知</li>
<li>在线用户:查看在线用户,管理用户连接</li>
</ul>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
console.log('Notice Center mounted')
})
</script>
<style scoped>
.notice-center {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.notice-content {
padding: 20px 0;
}
.notice-content ul {
margin-top: 15px;
padding-left: 20px;
}
.notice-content li {
margin-bottom: 8px;
line-height: 1.6;
}
</style>
+590
View File
@@ -0,0 +1,590 @@
<template>
<div class="notification-management">
<div class="gva-search-box">
<el-form
:inline="true"
:model="searchInfo"
class="demo-form-inline"
@keyup.enter="onSubmit"
>
<el-form-item label="标题">
<el-input v-model="searchInfo.title" placeholder="搜索条件" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="searchInfo.type" placeholder="请选择" clearable>
<el-option label="系统通知" value="system" />
<el-option label="业务通知" value="business" />
<el-option label="警告通知" value="warning" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchInfo.status" placeholder="请选择" clearable>
<el-option label="草稿" value="draft" />
<el-option label="已发送" value="sent" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit"
>查询</el-button
>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="plus" @click="openDialog"
>新增</el-button
>
<el-button
icon="delete"
style="margin-left: 10px"
:disabled="!multipleSelection.length"
@click="onDelete"
>删除</el-button
>
</div>
<el-table
ref="multipleTable"
style="width: 100%"
tooltip-effect="dark"
:data="tableData"
row-key="ID"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column align="left" label="日期" prop="CreatedAt" width="180">
<template #default="scope">
<span>{{ formatDate(scope.row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column align="left" label="标题" prop="title" />
<el-table-column align="left" label="类型" prop="type" width="120">
<template #default="scope">
<el-tag :type="getTypeTagType(scope.row.type)">{{
getTypeLabel(scope.row.type)
}}</el-tag>
</template>
</el-table-column>
<el-table-column
align="left"
label="目标类型"
prop="targetType"
width="120"
>
<template #default="scope">
<span>{{ getTargetTypeLabel(scope.row.targetType) }}</span>
</template>
</el-table-column>
<el-table-column align="left" label="状态" prop="status" width="100">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row.status)">{{
getStatusLabel(scope.row.status)
}}</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="操作" fixed="right" width="300">
<template #default="scope">
<el-button
type="primary"
link
icon="view"
size="small"
@click="getDetails(scope.row)"
>查看</el-button
>
<el-button
type="primary"
link
icon="edit"
size="small"
@click="updateNotificationFunc(scope.row)"
>变更</el-button
>
<el-button
type="primary"
link
icon="delete"
size="small"
@click="deleteRow(scope.row)"
>删除</el-button
>
<el-button
v-if="scope.row.status === 'draft'"
type="success"
link
icon="promotion"
size="small"
@click="sendNotificationFunc(scope.row)"
>发送</el-button
>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-drawer
destroy-on-close
size="800"
v-model="dialogFormVisible"
:show-close="false"
:before-close="closeDialog"
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">{{
type === 'create' ? '添加' : type === 'update' ? '修改' : '查看'
}}</span>
<div>
<el-button
v-if="type !== 'create'"
type="primary"
@click="updateNotificationFunc(formData)"
>变更</el-button
>
<el-button
v-if="type === 'create'"
type="primary"
@click="createNotificationFunc"
>添加</el-button
>
<el-button type="primary" @click="closeDialog">取消</el-button>
</div>
</div>
</template>
<el-form
:model="formData"
label-position="top"
ref="elFormRef"
:rules="rule"
label-width="80px"
>
<el-form-item label="标题:" prop="title">
<el-input
v-model="formData.title"
:clearable="true"
placeholder="请输入标题"
/>
</el-form-item>
<el-form-item label="内容:" prop="content">
<el-input
v-model="formData.content"
:clearable="true"
placeholder="请输入内容"
type="textarea"
:rows="4"
/>
</el-form-item>
<el-form-item label="类型:" prop="type">
<el-select
v-model="formData.type"
placeholder="请选择类型"
style="width: 100%"
>
<el-option label="系统通知" value="system" />
<el-option label="业务通知" value="business" />
<el-option label="警告通知" value="warning" />
</el-select>
</el-form-item>
<el-form-item label="目标类型:" prop="targetType">
<el-select
v-model="formData.targetType"
placeholder="请选择目标类型"
style="width: 100%"
@change="onTargetTypeChange"
>
<el-option label="全部用户" value="all" />
<el-option label="指定用户" value="users" />
<el-option label="指定角色" value="roles" />
</el-select>
</el-form-item>
<el-form-item
v-if="formData.targetType === 'users'"
label="目标用户:"
prop="targetIds"
>
<el-select
v-model="formData.targetIds"
placeholder="请选择用户"
multiple
style="width: 100%"
>
<el-option
v-for="user in userList"
:key="user.ID"
:label="user.nickName"
:value="user.ID"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="formData.targetType === 'roles'"
label="目标角色:"
prop="targetIds"
>
<el-select
v-model="formData.targetIds"
placeholder="请选择角色"
multiple
style="width: 100%"
>
<el-option
v-for="role in roleList"
:key="role.authorityId"
:label="role.authorityName"
:value="role.authorityId"
/>
</el-select>
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script setup>
import {
createNotification,
deleteNotification,
updateNotification,
getNotificationById,
getNotificationList,
publishNotification
} from '@/api/notice'
import { getUserList } from '@/api/user'
import { getAuthorityList } from '@/api/authority'
import { formatDate } from '@/utils/format'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref, reactive } from 'vue'
defineOptions({
name: 'NotificationManagement'
})
// 响应式数据
const formData = ref({
title: '',
content: '',
type: '',
targetType: '',
targetIds: []
})
const searchInfo = ref({
title: '',
type: '',
status: ''
})
const type = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const dialogFormVisible = ref(false)
const multipleSelection = ref([])
const userList = ref([])
const roleList = ref([])
// 表单验证规则
const rule = reactive({
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入内容', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
targetType: [
{ required: true, message: '请选择目标类型', trigger: 'change' }
]
})
const elFormRef = ref()
// 获取列表数据
const getTableData = async () => {
const table = await getNotificationList({
page: page.value,
pageSize: pageSize.value,
...searchInfo.value
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
getTableData()
// 获取用户列表
const getUserListData = async () => {
const res = await getUserList({ page: 1, pageSize: 999 })
if (res.code === 0) {
userList.value = res.data.list
}
}
// 获取角色列表
const getRoleListData = async () => {
const res = await getAuthorityList({ page: 1, pageSize: 999 })
if (res.code === 0) {
roleList.value = res.data.list
}
}
// 初始化数据
getUserListData()
getRoleListData()
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 查询
const onSubmit = () => {
page.value = 1
pageSize.value = 10
getTableData()
}
// 重置
const onReset = () => {
searchInfo.value = {
title: '',
type: '',
status: ''
}
getTableData()
}
// 多选
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
// 删除行
const deleteRow = (row) => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteNotificationFunc(row)
})
}
// 批量删除
const onDelete = async () => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const IDs = []
if (multipleSelection.value.length === 0) {
ElMessage({
type: 'warning',
message: '请选择要删除的数据'
})
return
}
multipleSelection.value &&
multipleSelection.value.map((item) => {
IDs.push(item.ID)
})
const res = await deleteNotification({ IDs })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === IDs.length && page.value > 1) {
page.value--
}
getTableData()
}
})
}
// 弹窗控制标记
const initForm = () => {
elFormRef.value?.resetFields()
formData.value = {
title: '',
content: '',
type: '',
targetType: '',
targetIds: []
}
}
// 关闭弹窗
const closeDialog = () => {
dialogFormVisible.value = false
initForm()
}
// 弹窗打开的标记
const openDialog = () => {
type.value = 'create'
dialogFormVisible.value = true
}
// 创建
const createNotificationFunc = async () => {
elFormRef.value?.validate(async (valid) => {
if (!valid) return
const res = await createNotification(formData.value)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '创建成功'
})
closeDialog()
getTableData()
}
})
}
// 更新
const updateNotificationFunc = async (row) => {
if (type.value === 'update') {
elFormRef.value?.validate(async (valid) => {
if (!valid) return
const res = await updateNotification(formData.value)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '更新成功'
})
closeDialog()
getTableData()
}
})
} else {
const res = await getNotificationById(row.id)
type.value = 'update'
if (res.code === 0) {
formData.value = res.data
formData.value.targetIds = res.data.targetIds.split(',') || []
dialogFormVisible.value = true
}
}
}
// 删除
const deleteNotificationFunc = async (row) => {
const res = await deleteNotification(row.id)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === 1 && page.value > 1) {
page.value--
}
getTableData()
}
}
// 发送通知
const sendNotificationFunc = async (row) => {
ElMessageBox.confirm('确定要发送这条通知吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(async () => {
console.log(row)
const res = await publishNotification(row.id)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '发送成功'
})
getTableData()
}
})
}
// 查看详情
const getDetails = async (row) => {
const res = await getNotificationById(row.id)
if (res.code === 0) {
formData.value = res.data
formData.value.targetIds = res.data.targetIds.split(',') || []
type.value = 'look'
dialogFormVisible.value = true
}
}
// 目标类型改变
const onTargetTypeChange = () => {
formData.value.targetIds = []
}
// 获取类型标签类型
const getTypeTagType = (type) => {
const typeMap = {
system: 'primary',
business: 'success',
warning: 'warning'
}
return typeMap[type] || ''
}
// 获取类型标签
const getTypeLabel = (type) => {
const typeMap = {
system: '系统通知',
business: '业务通知',
warning: '警告通知'
}
return typeMap[type] || type
}
// 获取目标类型标签
const getTargetTypeLabel = (targetType) => {
const targetTypeMap = {
all: '全部用户',
users: '指定用户',
roles: '指定角色'
}
return targetTypeMap[targetType] || targetType
}
// 获取状态标签类型
const getStatusTagType = (status) => {
const statusMap = {
draft: 'info',
published: 'success'
}
return statusMap[status] || ''
}
// 获取状态标签
const getStatusLabel = (status) => {
const statusMap = {
draft: '草稿',
published: '已发送'
}
return statusMap[status] || status
}
</script>
<style></style>
+447
View File
@@ -0,0 +1,447 @@
<template>
<div class="online-user-management">
<div class="gva-search-box">
<el-form
:inline="true"
:model="searchInfo"
class="demo-form-inline"
@keyup.enter="onSubmit"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="searchInfo.username"
placeholder="请输入用户名"
clearable
/>
</el-form-item>
<el-form-item label="昵称" prop="nickName">
<el-input
v-model="searchInfo.nickName"
placeholder="请输入昵称"
clearable
/>
</el-form-item>
<el-form-item label="角色ID" prop="roleId">
<el-input-number
v-model="searchInfo.roleId"
placeholder="请输入角色ID"
clearable
:min="1"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 统计卡片 -->
<div class="m-4">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="">
<div class="flex items-center justify-between">
<div
class="bg-green-5 rounded-full text-white text-2xl w-12 h-12 p-4 flex items-center justify-center"
>
<el-icon><User /></el-icon>
</div>
<div class="flex-1 flex items-center flex-col items-end">
<div class="text-3xl font-bold">
{{ onlineStats.totalOnline || 0 }}
</div>
<div class="text-sm mt-2 text-center text-black/60">
在线用户
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="">
<div class="flex items-center justify-between">
<div
class="bg-blue-5 rounded-full text-white text-2xl w-12 h-12 p-4 flex items-center justify-center today"
>
<el-icon><Calendar /></el-icon>
</div>
<div class="flex-1 flex items-center flex-col">
<div class="text-3xl font-bold">
{{ onlineStats.todayLogin || 0 }}
</div>
<div class="text-sm mt-2 text-center text-black/60">
今日登录
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="">
<div class="flex items-center justify-between">
<div
class="bg-orange-5 rounded-full text-white text-2xl w-12 h-12 p-4 flex items-center justify-center peak"
>
<el-icon><TrendCharts /></el-icon>
</div>
<div class="flex-1 flex items-center flex-col">
<div class="text-3xl font-bold">
{{ onlineStats.peakOnline || 0 }}
</div>
<div class="text-sm mt-2 text-center text-black/60">
峰值在线
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="">
<div class="flex items-center justify-between">
<div
class="bg-amber rounded-full text-white text-2xl w-12 h-12 p-4 flex items-center justify-center avg"
>
<el-icon><DataAnalysis /></el-icon>
</div>
<div class="flex-1 flex items-center flex-col">
<div class="text-3xl font-bold">
{{ onlineStats.avgOnline || 0 }}
</div>
<div class="text-sm mt-2 text-center text-black/60">
平均在线
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="refresh" @click="getTableData"
>刷新</el-button
>
<el-button
type="danger"
icon="close"
style="margin-left: 10px"
:disabled="!multipleSelection.length"
@click="onKickUsers"
>批量下线</el-button
>
</div>
<el-table
ref="multipleTable"
style="width: 100%"
tooltip-effect="dark"
:data="tableData"
row-key="ID"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column align="left" label="用户名" prop="username" />
<el-table-column align="left" label="昵称" prop="nickName" />
<el-table-column
align="left"
label="Socket ID"
prop="socketId"
width="280"
>
<template #default="scope">
<el-tag size="small">{{ scope.row.socketId }}</el-tag>
</template>
</el-table-column>
<el-table-column
align="left"
label="IP地址"
prop="ipAddress"
width="150"
/>
<!-- <el-table-column
align="left"
label="用户代理"
prop="userAgent"
width="200"
>
<template #default="scope">
<el-tooltip :content="scope.row.userAgent" placement="top">
<span class="user-agent-text">{{ scope.row.userAgent }}</span>
</el-tooltip>
</template>
</el-table-column> -->
<!-- <el-table-column
align="left"
label="登录时间"
prop="loginTime"
width="180"
>
<template #default="scope">
<span>{{ formatDate(scope.row.loginTime) }}</span>
</template>
</el-table-column> -->
<el-table-column
align="left"
label="最后活跃"
prop="lastActiveTime"
width="180"
>
<template #default="scope">
<span>{{ formatDate(scope.row.lastActiveTime) }}</span>
</template>
</el-table-column>
<el-table-column
align="left"
label="在线时长"
prop="onlineDuration"
width="120"
>
<template #default="scope">
<span>{{ formatDuration(scope.row.onlineDuration) }}</span>
</template>
</el-table-column>
<el-table-column align="left" label="状态" prop="status" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 'online' ? 'success' : 'info'">
{{ scope.row.status === 'online' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
align="left"
label="操作"
fixed="right"
min-width="120"
>
<template #default="scope">
<el-button
v-if="scope.row.status === 'online'"
type="danger"
link
icon="close"
size="small"
@click="kickUser(scope.row)"
>强制下线</el-button
>
<span v-else class="text-gray-400">已离线</span>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import {
getOnlineUserList,
kickUser as kickUserApi,
getOnlineUserStats
} from '@/api/notice'
import { formatDate } from '@/utils/format'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref, onMounted, onUnmounted } from 'vue'
import {
User,
Calendar,
TrendCharts,
DataAnalysis
} from '@element-plus/icons-vue'
defineOptions({
name: 'OnlineUserManagement'
})
// 响应式数据
const searchInfo = ref({
username: '',
nickName: '',
roleId: null
})
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const multipleSelection = ref([])
const onlineStats = ref({})
const refreshTimer = ref(null)
// 获取列表数据
const getTableData = async () => {
const table = await getOnlineUserList({
page: page.value,
pageSize: pageSize.value,
...searchInfo.value
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
// 获取统计数据
const getStatsData = async () => {
const res = await getOnlineUserStats()
if (res.code === 0) {
onlineStats.value = res.data
}
}
// 初始化数据
const initData = async () => {
await getTableData()
await getStatsData()
}
initData()
// 设置定时刷新
const startAutoRefresh = () => {
refreshTimer.value = setInterval(() => {
getTableData()
getStatsData()
}, 30000) // 30秒刷新一次
}
// 停止定时刷新
const stopAutoRefresh = () => {
if (refreshTimer.value) {
clearInterval(refreshTimer.value)
refreshTimer.value = null
}
}
onMounted(() => {
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 查询
const onSubmit = () => {
page.value = 1
pageSize.value = 10
getTableData()
}
// 重置
const onReset = () => {
searchInfo.value = {
username: '',
nickName: '',
roleId: null
}
getTableData()
}
// 多选
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
// 强制用户下线
const kickUser = (row) => {
ElMessageBox.confirm(`确定要强制用户 "${row.username}" 下线吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await kickUserApi(row.userId)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '操作成功'
})
getTableData()
}
})
}
// 批量强制下线
const onKickUsers = async () => {
if (multipleSelection.value.length === 0) {
ElMessage({
type: 'warning',
message: '请选择要下线的用户'
})
return
}
const onlineUsers = multipleSelection.value.filter(
(user) => user.status === 'online'
)
if (onlineUsers.length === 0) {
ElMessage({
type: 'warning',
message: '所选用户均已离线'
})
return
}
ElMessageBox.confirm(
`确定要强制 ${onlineUsers.length} 个用户下线吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
const promises = onlineUsers.map((user) => kickUserApi(user.userId))
try {
await Promise.all(promises)
ElMessage({
type: 'success',
message: '批量下线成功'
})
getTableData()
} catch (error) {
ElMessage({
type: 'error',
message: '部分用户下线失败'
})
}
})
}
// 格式化在线时长
const formatDuration = (seconds) => {
if (!seconds) return '0分钟'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else {
return `${minutes}分钟`
}
}
</script>
+667
View File
@@ -0,0 +1,667 @@
<template>
<div class="user-notification-center">
<!-- 页面头部 -->
<div class="gva-search-box">
<el-form
:inline="true"
:model="searchInfo"
class="demo-form-inline"
@keyup.enter="onSubmit"
>
<el-form-item label="通知类型">
<el-select v-model="searchInfo.type" placeholder="请选择" clearable>
<el-option label="系统通知" value="system" />
<el-option label="业务通知" value="business" />
<el-option label="警告通知" value="warning" />
</el-select>
</el-form-item>
<el-form-item label="阅读状态">
<el-select v-model="searchInfo.isRead" placeholder="请选择" clearable>
<el-option label="未读" :value="false" />
<el-option label="已读" :value="true" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select
v-model="searchInfo.priority"
placeholder="请选择"
clearable
>
<el-option label="低" value="low" />
<el-option label="普通" value="normal" />
<el-option label="高" value="high" />
<el-option label="紧急" value="urgent" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">
查询
</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 通知列表 -->
<div class="gva-table-box">
<div class="gva-btn-list mb-4">
<el-button
type="success"
icon="check"
:disabled="selectedNotifications.length === 0"
@click="batchMarkAsRead"
>
批量已读
</el-button>
<el-button
type="danger"
icon="delete"
:disabled="selectedNotifications.length === 0"
@click="batchDeleteNotifications"
>
批量删除
</el-button>
</div>
<el-table
ref="multipleTable"
:data="tableData"
style="width: 100%"
tooltip-effect="dark"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="title" label="标题" min-width="200">
<template #default="scope">
<div
class="notification-title"
:class="{ unread: !scope.row.isRead }"
>
<el-icon v-if="!scope.row.isRead" class="unread-dot">
<CircleCheck />
</el-icon>
{{ scope.row.title }}
</div>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="120">
<template #default="scope">
<el-tag :type="getTypeTagType(scope.row.type)" size="small">
{{ getTypeLabel(scope.row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="100">
<template #default="scope">
<el-tag :type="getPriorityTagType(scope.row.priority)" size="small">
{{ getPriorityLabel(scope.row.priority) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="isRead" label="状态" width="80">
<template #default="scope">
<el-tag
:type="scope.row.isRead ? 'success' : 'warning'"
size="small"
>
{{ scope.row.isRead ? '已读' : '未读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="发送时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="readTime" label="阅读时间" width="180">
<template #default="scope">
{{ scope.row.readTime ? formatDate(scope.row.readTime) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button
size="small"
type="primary"
@click.stop="viewNotification(scope.row)"
>
查看
</el-button>
<el-button
v-if="!scope.row.isRead"
size="small"
type="success"
@click.stop="markAsRead(scope.row)"
>
标记已读
</el-button>
<el-button
size="small"
type="danger"
@click.stop="deleteNotificationHandler(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 25, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 通知详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="通知详情"
width="60%"
:before-close="closeDetailDialog"
>
<div v-if="currentNotification" class="notification-detail">
<div class="detail-header">
<h3>{{ currentNotification.title }}</h3>
<div class="detail-meta">
<el-tag
:type="getTypeTagType(currentNotification.type)"
size="small"
>
{{ getTypeLabel(currentNotification.type) }}
</el-tag>
<el-tag
:type="getPriorityTagType(currentNotification.priority)"
size="small"
class="ml-2"
>
{{ getPriorityLabel(currentNotification.priority) }}
</el-tag>
<span class="detail-time ml-4">
发送时间:{{ formatDate(currentNotification.createdAt) }}
</span>
</div>
</div>
<div class="detail-content">
<div class="content-text" v-html="currentNotification.content"></div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button
v-if="currentNotification && !currentNotification.isRead"
type="success"
@click="markAsReadAndClose"
>
标记已读
</el-button>
<el-button type="danger" @click="deleteAndClose"> 删除 </el-button>
<el-button @click="closeDetailDialog">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheck } from '@element-plus/icons-vue'
import {
getUserNotificationList,
markNotificationAsRead,
getUnreadNotificationCount,
deleteNotification as deleteNotificationApi
} from '@/api/notice.js'
import { formatTimeToStr } from '@/utils/date'
defineOptions({
name: 'UserNotificationCenter'
})
// 响应式数据
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const tableData = ref([])
const loading = ref(false)
const selectedNotifications = ref([])
const detailDialogVisible = ref(false)
const currentNotification = ref(null)
// 搜索条件
const searchInfo = reactive({
type: '',
isRead: null,
priority: ''
})
// 统计信息
const stats = reactive({
total: 0,
unread: 0,
read: 0
})
// 获取通知列表
const getTableData = async () => {
loading.value = true
try {
const params = {
page: page.value,
pageSize: pageSize.value,
...searchInfo
}
const res = await getUserNotificationList(params)
if (res.code === 0) {
tableData.value = res.data.list || []
total.value = res.data.total || 0
}
} catch (error) {
console.error('获取通知列表失败:', error)
ElMessage.error('获取通知列表失败')
} finally {
loading.value = false
}
}
// 获取统计信息
const getStats = async () => {
try {
const res = await getUnreadNotificationCount()
if (res.code === 0) {
stats.total = res.data.total || 0
stats.unread = res.data.unread || 0
stats.read = res.data.read || 0
}
} catch (error) {
console.error('获取统计信息失败:', error)
}
}
// 搜索
const onSubmit = () => {
page.value = 1
getTableData()
}
// 重置搜索
const onReset = () => {
searchInfo.type = ''
searchInfo.isRead = null
searchInfo.priority = ''
page.value = 1
getTableData()
}
// 分页处理
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
page.value = 1
getTableData()
}
// 选择处理
const handleSelectionChange = (val) => {
selectedNotifications.value = val
}
// 行点击处理
const handleRowClick = (row) => {
viewNotification(row)
}
// 查看通知详情
const viewNotification = (row) => {
currentNotification.value = row
detailDialogVisible.value = true
// 如果是未读通知,自动标记为已读
if (!row.isRead) {
nextTick(() => {
markAsRead(row, false)
})
}
}
// 关闭详情弹窗
const closeDetailDialog = () => {
detailDialogVisible.value = false
currentNotification.value = null
}
// 标记单个通知为已读
const markAsRead = async (row, showMessage = true) => {
try {
const res = await markNotificationAsRead({
notificationIds: [row.notificationId]
})
if (res.code === 0) {
row.isRead = true
row.readTime = new Date()
if (showMessage) {
ElMessage.success('标记已读成功')
}
// 更新统计信息
getStats()
}
} catch (error) {
console.error('标记已读失败:', error)
ElMessage.error('标记已读失败')
}
}
// 标记已读并关闭弹窗
const markAsReadAndClose = async () => {
if (currentNotification.value) {
await markAsRead(currentNotification.value)
closeDetailDialog()
}
}
// 批量标记已读
const batchMarkAsRead = async () => {
if (selectedNotifications.value.length === 0) {
ElMessage.warning('请选择要标记的通知')
return
}
const unreadNotifications = selectedNotifications.value.filter(
(item) => !item.isRead
)
if (unreadNotifications.length === 0) {
ElMessage.warning('所选通知都已是已读状态')
return
}
try {
const notificationIds = unreadNotifications.map(
(item) => item.notificationId
)
const res = await markNotificationAsRead({ notificationIds })
if (res.code === 0) {
// 更新本地数据
unreadNotifications.forEach((item) => {
item.isRead = true
item.readTime = new Date()
})
ElMessage.success(`成功标记 ${unreadNotifications.length} 条通知为已读`)
getStats()
}
} catch (error) {
console.error('批量标记已读失败:', error)
ElMessage.error('批量标记已读失败')
}
}
// 删除单个通知
const deleteNotificationHandler = async (row) => {
ElMessageBox.confirm(
'确定要删除这条通知吗?如果此通知只针对您一人,删除后将无法恢复。',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const res = await deleteNotificationApi(row.notificationId)
if (res.code === 0) {
ElMessage.success('删除成功')
getTableData()
getStats()
}
} catch (error) {
console.error('删除通知失败:', error)
ElMessage.error('删除通知失败')
}
})
}
// 删除并关闭弹窗
const deleteAndClose = async () => {
if (currentNotification.value) {
await deleteNotificationHandler(currentNotification.value)
closeDetailDialog()
}
}
// 批量删除通知
const batchDeleteNotifications = async () => {
if (selectedNotifications.value.length === 0) {
ElMessage.warning('请选择要删除的通知')
return
}
ElMessageBox.confirm(
`确定要删除选中的 ${selectedNotifications.value.length} 条通知吗?如果通知只针对您一人,删除后将无法恢复。`,
'批量删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const deletePromises = selectedNotifications.value.map((item) =>
deleteNotificationApi(item.notificationId)
)
await Promise.all(deletePromises)
ElMessage.success(
`成功删除 ${selectedNotifications.value.length} 条通知`
)
getTableData()
getStats()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
}
})
}
// 工具函数
const formatDate = (date) => {
if (!date) return '-'
return formatTimeToStr(date, 'yyyy-mm-dd hh:MM:ss')
}
const getTypeLabel = (type) => {
const typeMap = {
system: '系统通知',
business: '业务通知',
warning: '警告通知'
}
return typeMap[type] || type
}
const getTypeTagType = (type) => {
const typeMap = {
system: 'info',
business: 'success',
warning: 'warning'
}
return typeMap[type] || 'info'
}
const getPriorityLabel = (priority) => {
const priorityMap = {
low: '低',
normal: '普通',
high: '高',
urgent: '紧急'
}
return priorityMap[priority] || priority
}
const getPriorityTagType = (priority) => {
const priorityMap = {
low: 'info',
normal: 'success',
high: 'warning',
urgent: 'danger'
}
return priorityMap[priority] || 'info'
}
// 初始化
onMounted(() => {
getTableData()
getStats()
})
</script>
<style scoped>
.user-notification-center {
padding: 20px;
}
.notification-stats {
margin-bottom: 20px;
}
.stats-card {
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stats-card.unread {
border-color: #f56c6c;
}
.stats-card.read {
border-color: #67c23a;
}
.stats-content {
padding: 20px;
}
.stats-number {
font-size: 28px;
font-weight: bold;
color: #409eff;
margin-bottom: 8px;
}
.stats-card.unread .stats-number {
color: #f56c6c;
}
.stats-card.read .stats-number {
color: #67c23a;
}
.stats-label {
font-size: 14px;
color: #909399;
}
.notification-title {
display: flex;
align-items: center;
font-weight: normal;
}
.notification-title.unread {
font-weight: bold;
color: #303133;
}
.unread-dot {
color: #f56c6c;
margin-right: 8px;
font-size: 8px;
}
.notification-detail {
max-height: 60vh;
overflow-y: auto;
}
.detail-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #ebeef5;
}
.detail-header h3 {
margin: 0 0 10px 0;
font-size: 18px;
color: #303133;
}
.detail-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.detail-time {
color: #909399;
font-size: 12px;
}
.detail-content {
line-height: 1.6;
}
.content-text {
color: #606266;
white-space: pre-wrap;
word-break: break-word;
}
.gva-pagination {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.ml-2 {
margin-left: 8px;
}
.ml-4 {
margin-left: 16px;
}
.mb-4 {
margin-bottom: 16px;
}
</style>
@@ -3,13 +3,39 @@
<warning-bar
title="获取字典且缓存方法已在前端utils/dictionary 已经封装完成 不必自己书写 使用方法查看文件内注释"
/>
<div class="flex gap-4 p-2">
<div class="flex gap-4">
<div
class="flex-none w-52 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded p-4"
class="flex-none w-64 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded p-4"
>
<div class="flex justify-between items-center">
<div class="flex justify-between items-center relative">
<span class="text font-bold">字典列表</span>
<el-button type="primary" @click="openDrawer"> 新增 </el-button>
<el-input
class="!absolute top-0 left-0 z-2 ease-in-out animate-slide-left"
placeholder="搜索"
v-if="showSearchInput"
v-model="searchName"
clearable
:autofocus="showSearchInput"
@clear="clearSearchInput"
:prefix-icon="Search"
v-click-outside="handleCloseSearchInput"
@keydown="handleInputKeyDown"
>
<template #append>
<el-button
:type="searchName ? 'primary' : 'info'"
@click="getTableData"
>搜索</el-button
>
</template>
</el-input>
<el-button
class="ml-auto"
:icon="Search"
@click="showSearchInputHandler"
></el-button>
<el-button type="primary" @click="openDrawer" :icon="Plus">
</el-button>
</div>
<el-scrollbar class="mt-4" style="height: calc(100vh - 300px)">
<div
@@ -23,7 +49,11 @@
"
@click="toDetail(dictionary)"
>
<span class="max-w-[160px] truncate">{{ dictionary.name }}</span>
<div class="max-w-[160px] truncate">
{{ dictionary.name }}
<span class="mr-auto text-sm">({{ dictionary.type }})</span>
</div>
<div class="min-w-[40px]">
<el-icon
class="text-blue-500"
@@ -119,8 +149,8 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import sysDictionaryDetail from './sysDictionaryDetail.vue'
import { Edit } from '@element-plus/icons-vue'
import { useAppStore } from "@/pinia";
import { Edit, Plus, Search } from '@element-plus/icons-vue'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'SysDictionary'
@@ -136,6 +166,8 @@
status: true,
desc: null
})
const searchName = ref('')
const showSearchInput = ref(false)
const rules = ref({
name: [
{
@@ -164,7 +196,9 @@
// 查询
const getTableData = async () => {
const res = await getSysDictionaryList()
const res = await getSysDictionaryList({
name: searchName.value.trim()
})
if (res.code === 0) {
dictionaryData.value = res.data
selectID.value = res.data[0].ID
@@ -241,6 +275,27 @@
drawerForm.value && drawerForm.value.clearValidate()
drawerFormVisible.value = true
}
const clearSearchInput = () => {
if (!showSearchInput.value) return
searchName.value = ''
showSearchInput.value = false
getTableData()
}
const handleCloseSearchInput = () => {
if (!showSearchInput.value || searchName.value.trim() != '') return
showSearchInput.value = false
}
const showSearchInputHandler = () => {
showSearchInput.value = true
}
const handleInputKeyDown = (e) => {
if (e.key === 'Enter' && searchName.value.trim() !== '') {
getTableData()
}
}
</script>
<style>
@@ -1,8 +1,26 @@
<template>
<div>
<div class="gva-table-box">
<div class="gva-btn-list justify-between">
<div class="gva-btn-list justify-between flex items-center">
<span class="text font-bold">字典详细内容</span>
<el-input
placeholder="搜索展示值"
v-model="searchName"
clearable
class="!w-64 ml-auto"
@clear="clearSearchInput"
:prefix-icon="Search"
v-click-outside="handleCloseSearchInput"
@keydown="handleInputKeyDown"
>
<template #append>
<el-button
:type="searchName ? 'primary' : 'info'"
@click="getTableData"
>搜索</el-button
>
</template>
</el-input>
<el-button type="primary" icon="plus" @click="openDrawer">
新增字典项
</el-button>
@@ -45,7 +63,11 @@
width="120"
/>
<el-table-column align="left" label="操作" :min-width="appStore.operateMinWith">
<el-table-column
align="left"
label="操作"
:min-width="appStore.operateMinWith"
>
<template #default="scope">
<el-button
type="primary"
@@ -156,14 +178,14 @@
import { ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatBoolean, formatDate } from '@/utils/format'
import { useAppStore } from "@/pinia";
import { useAppStore } from '@/pinia'
defineOptions({
name: 'SysDictionaryDetail'
})
const appStore = useAppStore()
const searchName = ref('')
const props = defineProps({
sysDictionaryID: {
type: Number,
@@ -223,7 +245,8 @@
const table = await getSysDictionaryDetailList({
page: page.value,
pageSize: pageSize.value,
sysDictionaryID: props.sysDictionaryID
sysDictionaryID: props.sysDictionaryID,
label: searchName.value.trim()
})
if (table.code === 0) {
tableData.value = table.data.list
+517
View File
@@ -0,0 +1,517 @@
<template>
<div class="user-notification-center">
<!-- 页面标题 -->
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="Refresh" @click="getTableData">刷新</el-button>
<el-button
type="success"
icon="Check"
:disabled="!multipleSelection.length"
@click="batchMarkAsRead"
>
批量标记已读
</el-button>
<el-button
type="danger"
icon="Delete"
:disabled="!multipleSelection.length"
@click="batchDelete"
>
批量删除
</el-button>
</div>
<!-- 搜索区域 -->
<div class="gva-search-box">
<el-form :inline="true" :model="searchInfo" class="demo-form-inline" @keyup.enter="onSubmit">
<el-form-item label="通知类型">
<el-select v-model="searchInfo.type" placeholder="请选择通知类型" clearable>
<el-option label="公告" value="announcement" />
<el-option label="通知" value="notification" />
<el-option label="警告" value="warning" />
<el-option label="违规" value="violation" />
<el-option label="信息" value="info" />
</el-select>
</el-form-item>
<el-form-item label="阅读状态">
<el-select v-model="searchInfo.isRead" placeholder="请选择阅读状态" clearable>
<el-option label="未读" :value="false" />
<el-option label="已读" :value="true" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="searchInfo.priority" placeholder="请选择优先级" clearable>
<el-option label="低" value="low" />
<el-option label="中" value="medium" />
<el-option label="高" value="high" />
<el-option label="紧急" value="urgent" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 统计信息 -->
<div class="notification-stats">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-number">{{ stats.totalCount }}</div>
<div class="stats-label">总通知数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card unread">
<div class="stats-item">
<div class="stats-number">{{ stats.unreadCount }}</div>
<div class="stats-label">未读通知</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card read">
<div class="stats-item">
<div class="stats-number">{{ stats.readCount }}</div>
<div class="stats-label">已读通知</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card">
<div class="stats-item">
<div class="stats-number">{{ unreadPercentage }}%</div>
<div class="stats-label">未读率</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 通知列表 -->
<el-table
ref="multipleTable"
style="width: 100%"
tooltip-effect="dark"
:data="tableData"
row-key="ID"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
>
<el-table-column type="selection" width="55" />
<el-table-column align="left" label="日期" prop="createdAt" width="180">
<template #default="scope">
<span>{{ formatDate(scope.row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column align="left" label="标题" prop="title" show-overflow-tooltip />
<el-table-column align="left" label="类型" prop="type" width="100">
<template #default="scope">
<el-tag :type="getTypeTagType(scope.row.type)">{{ getTypeLabel(scope.row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="优先级" prop="priority" width="100">
<template #default="scope">
<el-tag :type="getPriorityTagType(scope.row.priority)">{{ getPriorityLabel(scope.row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="阅读状态" prop="isRead" width="100">
<template #default="scope">
<el-tag :type="scope.row.isRead ? 'success' : 'warning'">
{{ scope.row.isRead ? '已读' : '未读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="left" label="阅读时间" prop="readTime" width="180">
<template #default="scope">
<span>{{ scope.row.readTime ? formatDate(scope.row.readTime) : '-' }}</span>
</template>
</el-table-column>
<el-table-column align="left" label="操作" fixed="right" width="240">
<template #default="scope">
<el-button type="primary" link icon="view" size="small" @click="viewNotification(scope.row)">查看</el-button>
<el-button
v-if="!scope.row.isRead"
type="success"
link
icon="check"
size="small"
@click="markAsRead(scope.row)"
>
标记已读
</el-button>
<el-button type="danger" link icon="delete" size="small" @click="deleteNotificationHandler(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="gva-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 通知详情弹窗 -->
<el-dialog v-model="detailVisible" title="通知详情" width="60%" :before-close="closeDetail">
<div v-if="currentNotification" class="notification-detail">
<div class="detail-header">
<h3>{{ currentNotification.title }}</h3>
<div class="detail-meta">
<el-tag :type="getTypeTagType(currentNotification.type)">{{ getTypeLabel(currentNotification.type) }}</el-tag>
<el-tag :type="getPriorityTagType(currentNotification.priority)">{{ getPriorityLabel(currentNotification.priority) }}</el-tag>
<el-tag :type="currentNotification.isRead ? 'success' : 'warning'">
{{ currentNotification.isRead ? '已读' : '未读' }}
</el-tag>
</div>
</div>
<div class="detail-content">
<div v-html="currentNotification.content"></div>
</div>
<div class="detail-footer">
<p><strong>发送时间:</strong>{{ formatDate(currentNotification.createdAt) }}</p>
<p v-if="currentNotification.readTime"><strong>阅读时间:</strong>{{ formatDate(currentNotification.readTime) }}</p>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button v-if="!currentNotification?.isRead" type="success" @click="markAsReadInDetail">标记已读</el-button>
<el-button type="danger" @click="deleteInDetail">删除</el-button>
<el-button @click="closeDetail">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatDate } from '@/utils/format'
import { getUserNotificationList, markNotificationAsRead, deleteUserNotification } from '@/api/notice'
defineOptions({
name: 'UserNotificationCenter'
})
// 响应式数据
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const multipleSelection = ref([])
const detailVisible = ref(false)
const currentNotification = ref(null)
// 搜索条件
const searchInfo = reactive({
type: '',
isRead: null,
priority: ''
})
// 统计信息
const stats = reactive({
totalCount: 0,
unreadCount: 0,
readCount: 0
})
// 计算未读率
const unreadPercentage = computed(() => {
if (stats.totalCount === 0) return 0
return Math.round((stats.unreadCount / stats.totalCount) * 100)
})
// 获取表格数据
const getTableData = async () => {
const table = await getUserNotificationList({
page: page.value,
pageSize: pageSize.value,
...searchInfo
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
// 更新统计信息
stats.totalCount = table.data.total
stats.unreadCount = table.data.list.filter(item => !item.isRead).length
stats.readCount = table.data.list.filter(item => item.isRead).length
}
}
// 分页相关
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 多选相关
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
// 搜索相关
const onSubmit = () => {
page.value = 1
pageSize.value = 10
getTableData()
}
const onReset = () => {
searchInfo.type = ''
searchInfo.isRead = null
searchInfo.priority = ''
getTableData()
}
// 查看通知详情
const viewNotification = (row) => {
currentNotification.value = row
detailVisible.value = true
// 如果是未读通知,自动标记为已读
if (!row.isRead) {
markAsRead(row, false)
}
}
// 关闭详情弹窗
const closeDetail = () => {
detailVisible.value = false
currentNotification.value = null
}
// 行点击事件
const handleRowClick = (row) => {
viewNotification(row)
}
// 标记已读
const markAsRead = async (row, showMessage = true) => {
const res = await markNotificationAsRead({
notificationIds: [row.notificationId]
})
if (res.code === 0) {
row.isRead = true
row.readTime = new Date().toISOString()
if (showMessage) {
ElMessage.success('标记已读成功')
}
getTableData()
}
}
// 在详情中标记已读
const markAsReadInDetail = async () => {
await markAsRead(currentNotification.value)
currentNotification.value.isRead = true
currentNotification.value.readTime = new Date().toISOString()
}
// 批量标记已读
const batchMarkAsRead = async () => {
const notificationIds = multipleSelection.value.map(item => item.notificationId)
const res = await markNotificationAsRead({
notificationIds
})
if (res.code === 0) {
ElMessage.success('批量标记已读成功')
getTableData()
}
}
// 删除通知
const deleteNotificationHandler = async (row) => {
ElMessageBox.confirm('此操作将永久删除该通知, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await deleteUserNotification(row.notificationId)
if (res.code === 0) {
ElMessage.success('删除成功')
getTableData()
}
})
}
// 在详情中删除
const deleteInDetail = async () => {
ElMessageBox.confirm('此操作将永久删除该通知, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await deleteUserNotification(currentNotification.value.notificationId)
if (res.code === 0) {
ElMessage.success('删除成功')
closeDetail()
getTableData()
}
})
}
// 批量删除
const batchDelete = async () => {
ElMessageBox.confirm('此操作将永久删除选中的通知, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const promises = multipleSelection.value.map(item => deleteUserNotification(item.notificationId))
await Promise.all(promises)
ElMessage.success('批量删除成功')
getTableData()
})
}
// 获取类型标签样式
const getTypeTagType = (type) => {
const typeMap = {
announcement: '',
notification: 'success',
warning: 'warning',
violation: 'danger',
info: 'info'
}
return typeMap[type] || ''
}
// 获取类型标签文本
const getTypeLabel = (type) => {
const typeMap = {
announcement: '公告',
notification: '通知',
warning: '警告',
violation: '违规',
info: '信息'
}
return typeMap[type] || type
}
// 获取优先级标签样式
const getPriorityTagType = (priority) => {
const priorityMap = {
low: 'info',
medium: '',
high: 'warning',
urgent: 'danger'
}
return priorityMap[priority] || ''
}
// 获取优先级标签文本
const getPriorityLabel = (priority) => {
const priorityMap = {
low: '低',
medium: '中',
high: '高',
urgent: '紧急'
}
return priorityMap[priority] || priority
}
// 组件挂载时获取数据
onMounted(() => {
getTableData()
})
</script>
<style lang="scss" scoped>
.user-notification-center {
.notification-stats {
margin-bottom: 20px;
.stats-card {
text-align: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.unread {
border-left: 4px solid #f56c6c;
}
&.read {
border-left: 4px solid #67c23a;
}
.stats-item {
.stats-number {
font-size: 28px;
font-weight: bold;
color: #303133;
margin-bottom: 8px;
}
.stats-label {
font-size: 14px;
color: #909399;
}
}
}
}
.notification-detail {
.detail-header {
border-bottom: 1px solid #ebeef5;
padding-bottom: 16px;
margin-bottom: 20px;
h3 {
margin: 0 0 12px 0;
color: #303133;
}
.detail-meta {
.el-tag {
margin-right: 8px;
}
}
}
.detail-content {
line-height: 1.6;
color: #606266;
margin-bottom: 20px;
min-height: 100px;
}
.detail-footer {
border-top: 1px solid #ebeef5;
padding-top: 16px;
color: #909399;
font-size: 14px;
p {
margin: 8px 0;
}
}
}
}
</style>