add admin users

This commit is contained in:
fancl 2024-12-13 10:37:09 +08:00
parent b49c431a8e
commit 46ee4ddd6f
38 changed files with 551 additions and 831 deletions

92
api.go
View File

@ -1,8 +1,10 @@
package moto
import (
"embed"
"git.nobla.cn/golang/kos"
"git.nobla.cn/golang/kos/entry/http"
"git.nobla.cn/golang/kos/util/arrays"
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/internal/organize"
"git.nobla.cn/golang/moto/internal/organize/passport"
@ -11,9 +13,20 @@ import (
"git.nobla.cn/golang/rest"
restTypes "git.nobla.cn/golang/rest/types"
"gorm.io/gorm"
httpkg "net/http"
"strconv"
)
//go:embed web/release
var webDir embed.FS
type (
resetPasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
)
func (svr *Server) handleLogin(ctx *http.Context) (err error) {
var (
tk *types.Tokenize
@ -26,9 +39,27 @@ func (svr *Server) handleLogin(ctx *http.Context) (err error) {
if tk, err = passport.Login(ctx.Context(), req); err != nil {
return ctx.Error(http.ErrPermissionDenied, err.Error())
}
ctx.SetCookie(&httpkg.Cookie{Name: organize.CookieName, Value: tk.Token, Path: "/"})
return ctx.Success(tk)
}
func (svr *Server) handleResetPassword(ctx *http.Context) (err error) {
req := &resetPasswordRequest{}
if err = ctx.Bind(req); err != nil {
return ctx.Error(http.ErrInvalidPayload, err.Error())
}
userid := ctx.User().ID
if err = passport.ResetPassword(
ctx.Request().Context(),
userid,
req.OldPassword,
req.NewPassword,
); err != nil {
return ctx.Error(http.ErrResourceUpdate, err.Error())
}
return ctx.Success("OK")
}
func (svr *Server) handleLogout(ctx *http.Context) (err error) {
passport.Logout(ctx.Context(), ctx.User().Get("token"))
return ctx.Success("logout")
@ -41,9 +72,27 @@ func (svr *Server) handleProfile(ctx *http.Context) (err error) {
if profile, err = organize.Profile(ctx.Context(), ctx.User().ID); err != nil {
return ctx.Error(http.ErrTemporaryUnavailable, err.Error())
}
if arrays.Exists(ctx.User().ID, svr.cfg.AdminUsers) {
profile.Admin = true
}
return ctx.Success(profile)
}
func (svr *Server) handleUpdateProfile(ctx *http.Context) (err error) {
var (
profile *types.Profile
)
profile = &types.Profile{}
if err = ctx.Bind(profile); err != nil {
return ctx.Error(http.ErrInvalidPayload, err.Error())
}
if err = organize.UpdateProfile(ctx.Context(), ctx.User().ID, profile); err == nil {
return ctx.Success(profile)
} else {
return ctx.Error(http.ErrTemporaryUnavailable, err.Error())
}
}
func (svr *Server) handleGetConfigure(ctx *http.Context) (err error) {
return ctx.Success(map[string]string{})
}
@ -57,16 +106,16 @@ func (svr *Server) handleListSchema(ctx *http.Context) (err error) {
schemas, err = rest.GetSchemas(
ctx.Request().Context(),
db.WithContext(ctx.Request().Context()),
ctx.User().Get("domain"),
version.ProductName,
"",
version.ModuleName,
ctx.Param("table"),
)
} else {
schemas, err = rest.VisibleSchemas(
ctx.Request().Context(),
db.WithContext(ctx.Request().Context()),
ctx.User().Get("domain"),
version.ProductName,
"",
version.ModuleName,
ctx.Param("table"),
scenario,
)
@ -116,17 +165,48 @@ func (svr *Server) handleDeleteSchema(ctx *http.Context) (err error) {
}
}
func (svr *Server) handleDepartmentTypes(ctx *http.Context) (err error) {
return ctx.Success(organize.DepartmentTypes(ctx.Context()))
}
func (svr *Server) handleRoleTypes(ctx *http.Context) (err error) {
return ctx.Success(organize.RoleTypes(ctx.Context()))
}
func (svr *Server) handleUserTypes(ctx *http.Context) (err error) {
return ctx.Success(organize.UserTypes(ctx.Context()))
}
func (svr *Server) handleUserTags(ctx *http.Context) (err error) {
return ctx.Success(organize.UserTags(ctx.Context()))
}
func (svr *Server) handleUserAvatar(ctx *http.Context) (err error) {
organize.Avatar(ctx.Context(), svr.cfg.Avatar.Dirname, ctx.Param("id"), ctx.Request(), ctx.Response())
return
}
func (svr *Server) routes() {
kos.Http().Use(organize.AuthMiddleware)
organize.AllowUri("/passport/login")
kos.Http().Root("/web/release", httpkg.FS(webDir))
kos.Http().Handle(http.MethodPost, "/passport/login", svr.handleLogin)
kos.Http().Handle(http.MethodPut, "/passport/reset-password", svr.handleResetPassword)
kos.Http().Handle(http.MethodDelete, "/passport/logout", svr.handleLogout)
kos.Http().Handle(http.MethodGet, "/user/profile", svr.handleProfile)
kos.Http().Handle(http.MethodPut, "/user/profile", svr.handleUpdateProfile)
kos.Http().Handle(http.MethodGet, "/user/configures", svr.handleGetConfigure)
kos.Http().Handle(http.MethodGet, "/rest/schema/:module/:table", svr.handleListSchema)
kos.Http().Handle(http.MethodPut, "/rest/schema/:module/:table", svr.handleSaveSchema)
kos.Http().Handle(http.MethodGet, "/rest/schema/:table", svr.handleListSchema)
kos.Http().Handle(http.MethodPut, "/rest/schema/:table", svr.handleSaveSchema)
kos.Http().Handle(http.MethodDelete, "/rest/schema/:id", svr.handleDeleteSchema)
kos.Http().Handle(http.MethodGet, "/organize/department-types", svr.handleDepartmentTypes)
kos.Http().Handle(http.MethodGet, "/organize/role-types", svr.handleRoleTypes)
kos.Http().Handle(http.MethodGet, "/organize/user-types", svr.handleUserTypes)
kos.Http().Handle(http.MethodGet, "/organize/user-tags", svr.handleUserTags)
kos.Http().Handle(http.MethodGet, "/profile/avatar/:id", svr.handleUserAvatar)
}

View File

@ -2,13 +2,13 @@ package db
import (
"context"
"git.nobla.cn/golang/kos"
"git.nobla.cn/golang/moto/config"
"git.nobla.cn/golang/rest/types"
"github.com/go-sql-driver/mysql"
lru "github.com/hashicorp/golang-lru/v2"
mysqlDriver "gorm.io/driver/mysql"
"gorm.io/gorm"
"reflect"
"time"
)
@ -31,7 +31,7 @@ func TryCache(ctx context.Context, key string, f CachingFunc, cbs ...CacheOption
var (
ok bool
hasDependValue bool
dependValue any
dependValue string
)
opts := &CacheOptions{}
for _, cb := range cbs {
@ -50,7 +50,7 @@ func TryCache(ctx context.Context, key string, f CachingFunc, cbs ...CacheOption
//对比依赖值
if err = WithContext(ctx).Raw(opts.dependSQL, opts.dependArgs...).Scan(&dependValue).Error; err == nil {
hasDependValue = true
if reflect.DeepEqual(entry.compareValue, dependValue) {
if entry.compareValue == dependValue {
entry.lastChecked = time.Now()
return entry.storeValue, nil
} else {
@ -104,6 +104,9 @@ func Init(ctx context.Context, cfg config.Database, plugins ...gorm.Plugin) (err
return
}
db = db.WithContext(ctx)
if kos.Debug() {
db = db.Debug()
}
for _, plugin := range plugins {
if err = db.Use(plugin); err != nil {
return

View File

@ -17,7 +17,7 @@ type (
cacheEntry struct {
storeValue any
compareValue any
compareValue string
createdAt time.Time
lastChecked time.Time
}

View File

@ -1,5 +1,7 @@
package config
import "os"
type Database struct {
Address string `json:"address" yaml:"address"`
Username string `json:"username" yaml:"username"`
@ -7,11 +9,18 @@ type Database struct {
Database string `json:"database" yaml:"database"`
}
type Avatar struct {
Dirname string `json:"dirname" yaml:"dirname"`
}
type Config struct {
Database Database `json:"database" yaml:"database"`
Database Database `json:"database" yaml:"database"`
Avatar Avatar `json:"avatar" yaml:"avatar"`
AdminUsers []string `json:"admin_users" yaml:"adminUsers"`
}
func New() *Config {
cfg := &Config{}
cfg.Avatar.Dirname = os.TempDir()
return cfg
}

View File

@ -3,3 +3,6 @@ database:
username: "root"
password: "root"
database: "test2"
adminUsers:
- "1000"

7
go.mod
View File

@ -4,7 +4,8 @@ go 1.22.9
require (
git.nobla.cn/golang/kos v0.1.32
git.nobla.cn/golang/rest v0.0.2
git.nobla.cn/golang/rest v0.0.4
github.com/disintegration/letteravatar v0.0.0-20160912210445-1a457b860450
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7
@ -20,6 +21,7 @@ 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.23.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/text v0.2.0 // indirect
@ -30,7 +32,8 @@ require (
github.com/rs/xid v1.6.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

14
go.sum
View File

@ -2,11 +2,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.nobla.cn/golang/kos v0.1.32 h1:sFVCA7vKc8dPUd0cxzwExOSPX2mmMh2IuwL6cYS1pBc=
git.nobla.cn/golang/kos v0.1.32/go.mod h1:35Z070+5oB39WcVrh5DDlnVeftL/Ccmscw2MZFe9fUg=
git.nobla.cn/golang/rest v0.0.2 h1:b5hmGuVb1zXFQ8O2AYCi8628JGftgH2TL5BLftCN1OU=
git.nobla.cn/golang/rest v0.0.2/go.mod h1:tGDOul2GGJtxk6fAeu+YLpMt/Up/TsBonTkygymN/wE=
git.nobla.cn/golang/rest v0.0.4 h1:i8hD/z5UAgoKjRA0ED83yK0q0IuYJ+xiiUZ1nGdn8PY=
git.nobla.cn/golang/rest v0.0.4/go.mod h1:tGDOul2GGJtxk6fAeu+YLpMt/Up/TsBonTkygymN/wE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/letteravatar v0.0.0-20160912210445-1a457b860450 h1:bONK4hIXo1mYT3u9dtW7mH7xsy6PwzwxW//IxAlPrbI=
github.com/disintegration/letteravatar v0.0.0-20160912210445-1a457b860450/go.mod h1:/xRvTkTQVbtTtCoVg1yFA4/mrCtaqEw5SaHacBbcT5I=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -20,6 +22,8 @@ github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaC
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@ -54,13 +58,15 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -0,0 +1,56 @@
package organize
import (
"context"
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/models"
"github.com/disintegration/letteravatar"
"image"
"image/png"
"mime"
"net/http"
"os"
"path"
"strings"
"time"
"unicode/utf8"
)
func Avatar(ctx context.Context, dirname, userid string, r *http.Request, w http.ResponseWriter) {
var (
fs os.FileInfo
fp *os.File
err error
img image.Image
modTime time.Time
)
tx := db.DB().WithContext(ctx)
extName := ".png"
if dirname != "" {
filename := path.Join(dirname, userid+extName)
if fp, err = os.Open(filename); err == nil {
defer fp.Close()
if fs, err = os.Stat(filename); err == nil {
modTime = fs.ModTime()
}
http.ServeContent(w, r, userid+extName, modTime, fp)
return
}
}
userModel := &models.User{}
if err = tx.Where("uid=?", userid).First(userModel).Error; err != nil {
return
}
firstLetter, _ := utf8.DecodeRuneInString(strings.ToUpper(userModel.Username))
if img, err = letteravatar.Draw(64, firstLetter, nil); err == nil {
w.Header().Set("Content-Type", mime.TypeByExtension(extName))
if dirname != "" {
filename := path.Join(dirname, userid+extName)
if fp, err = os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644); err == nil {
err = png.Encode(fp, img)
err = fp.Close()
}
}
err = png.Encode(w, img)
}
}

View File

@ -47,7 +47,7 @@ func RecursiveNestedDepartment(ctx context.Context, parent string, level int, de
return values
}
func DepartmentUserNested(ctx context.Context, domainName string) []*types.NestedValue {
func DepartmentUserNested(ctx context.Context) []*types.NestedValue {
var (
err error
value any
@ -55,21 +55,21 @@ func DepartmentUserNested(ctx context.Context, domainName string) []*types.Neste
departments []*models.Department
)
if value, err = db.TryCache(ctx, fmt.Sprintf("domain:%s:departments", domainName), func(tx *gorm.DB) (any, error) {
if value, err = db.TryCache(ctx, fmt.Sprintf("departments"), func(tx *gorm.DB) (any, error) {
departments = make([]*models.Department, 0)
err = tx.Where("domain=?", domainName).Order("created_at ASC").Find(&departments).Error
err = tx.Order("created_at ASC").Find(&departments).Error
return departments, err
}, db.WithDepend("SELECT max(`updated_at`) FROM `departments` WHERE `domain`=?", domainName)); err == nil {
}, db.WithDepend("SELECT max(`updated_at`) FROM `departments`")); err == nil {
departments = value.([]*models.Department)
} else {
return nil
}
if value, err = db.TryCache(ctx, fmt.Sprintf("domain:%s:users", domainName), func(tx *gorm.DB) (any, error) {
if value, err = db.TryCache(ctx, fmt.Sprintf("users"), func(tx *gorm.DB) (any, error) {
users = make([]*models.User, 0)
err = tx.Where("domain=?", domainName).Order("uid ASC").Find(&users).Error
err = tx.Order("uid ASC").Find(&users).Error
return users, err
}, db.WithDepend("SELECT max(`updated_at`) FROM `users` WHERE `domain`=?", domainName)); err == nil {
}, db.WithDepend("SELECT max(`updated_at`) FROM `users`")); err == nil {
users = value.([]*models.User)
} else {
return nil
@ -96,15 +96,15 @@ func DepartmentUserNested(ctx context.Context, domainName string) []*types.Neste
return values
}
func DepartmentTypes(ctx context.Context, domainName string) []*types.TypeValue {
result, err := db.TryCache(ctx, fmt.Sprintf("domain:%s:department:types", domainName), func(tx *gorm.DB) (any, error) {
func DepartmentTypes(ctx context.Context) []*types.TypeValue {
result, err := db.TryCache(ctx, fmt.Sprintf("department:types"), func(tx *gorm.DB) (any, error) {
values := make([]*models.Department, 0)
if err := db.WithContext(ctx).Where("domain=?", domainName).Find(&values).Error; err == nil {
if err := db.WithContext(ctx).Find(&values).Error; err == nil {
return RecursiveDepartment(ctx, "", 0, values), nil
} else {
return nil, err
}
}, db.WithDepend("SELECT max(`updated_at`) FROM `departments` WHERE `domain`=?", domainName))
}, db.WithDepend("SELECT max(`updated_at`) FROM `departments`"))
if err == nil {
return result.([]*types.TypeValue)
}

View File

@ -0,0 +1,22 @@
package passport
import (
"context"
"fmt"
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/models"
)
func ResetPassword(ctx context.Context, userid string, oldPassword, newPassword string) (err error) {
tx := db.WithContext(ctx)
userModel := &models.User{}
if err = tx.Where("uid=?", userid).First(userModel).Error; err != nil {
return
}
if userModel.Password == oldPassword || md5Hash(oldPassword) == userModel.Password {
err = tx.Where("uid=?", userid).Model(&models.User{}).UpdateColumn("password", md5Hash(newPassword)).Error
} else {
err = fmt.Errorf("invalid password")
}
return
}

View File

@ -4,6 +4,7 @@ import (
"context"
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/internal/organize/types"
"git.nobla.cn/golang/moto/models"
)
func Profile(ctx context.Context, uid string) (profile *types.Profile, err error) {
@ -15,3 +16,25 @@ func Profile(ctx context.Context, uid string) (profile *types.Profile, err error
First(profile).Error
return
}
func UpdateProfile(ctx context.Context, uid string, model *types.Profile) (err error) {
tx := db.DB().WithContext(ctx)
userModel := &models.User{}
if err = tx.Where("uid=?", uid).First(userModel).Error; err != nil {
return
}
if model.Avatar != "" {
userModel.Avatar = model.Avatar
}
if model.Email != "" {
userModel.Email = model.Email
}
if model.Username != "" {
userModel.Username = model.Username
}
if model.Description != "" {
userModel.Description = model.Description
}
err = tx.Save(userModel).Error
return
}

View File

@ -10,10 +10,10 @@ import (
"gorm.io/gorm"
)
func RoleTypes(ctx context.Context, domainName string) []*types.TypeValue {
result, err := db.TryCache(ctx, fmt.Sprintf("domain:%s:role:types", domainName), func(tx *gorm.DB) (any, error) {
return rest.ModelTypes(ctx, tx, &models.Role{}, domainName, "name", "id"), nil
}, db.WithDepend("SELECT max(`updated_at`) FROM `roles` WHERE `domain`=?", domainName))
func RoleTypes(ctx context.Context) []*types.TypeValue {
result, err := db.TryCache(ctx, fmt.Sprintf("role:types"), func(tx *gorm.DB) (any, error) {
return rest.ModelTypes(ctx, tx, &models.Role{}, "", "name", "id"), nil
}, db.WithDepend("SELECT max(`updated_at`) FROM `roles`"))
if err == nil {
return result.([]*types.TypeValue)
}

View File

@ -7,6 +7,7 @@ type (
Role string `json:"role"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Admin bool `json:"admin"`
Description string `json:"description"`
Permissions string `json:"permissions"`
}

View File

@ -9,10 +9,10 @@ import (
"gorm.io/gorm"
)
func UserTypes(ctx context.Context, domainName string) []*types.TypeValue {
result, err := db.TryCache(ctx, fmt.Sprintf("domain:%s:user:types", domainName), func(tx *gorm.DB) (any, error) {
func UserTypes(ctx context.Context) []*types.TypeValue {
result, err := db.TryCache(ctx, fmt.Sprintf("user:types"), func(tx *gorm.DB) (any, error) {
values := make([]*models.User, 0)
err := tx.Where("domain=?", domainName).Order("uid ASC").Find(&values).Error
err := tx.Order("uid ASC").Find(&values).Error
if err != nil {
return nil, err
}
@ -24,9 +24,34 @@ func UserTypes(ctx context.Context, domainName string) []*types.TypeValue {
})
}
return data, nil
}, db.WithDepend("SELECT max(`updated_at`) FROM `users` WHERE `domain`=?", domainName))
}, db.WithDepend("SELECT max(`updated_at`) FROM `users`"))
if err == nil {
return result.([]*types.TypeValue)
}
return nil
}
func UserTags(ctx context.Context) []*types.TypeValue {
result, err := db.TryCache(ctx, fmt.Sprintf("users:tags"), func(tx *gorm.DB) (any, error) {
values := make([]*models.User, 0)
if errTx := tx.Select("DISTINCT(`tag`) AS `tag`").Find(&values).Error; errTx == nil {
vs := make([]*types.TypeValue, 0, len(values))
for _, row := range values {
if row.Tag != "" {
vs = append(vs, &types.TypeValue{
Label: row.Tag,
Value: row.Tag,
})
}
}
return vs, nil
} else {
return nil, errTx
}
}, db.WithDepend("SELECT max(`updated_at`) FROM `users`"))
if err == nil {
return result.([]*types.TypeValue)
} else {
return []*types.TypeValue{}
}
}

76
migration.go 100644
View File

@ -0,0 +1,76 @@
package moto
import (
"context"
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/models"
)
var (
defaultUsers = map[string]string{
"1000": "admin",
}
defaultDepartments = map[string]string{
"admin": "客服部",
}
defaultRoles = map[string]string{
"admin": "管理员",
}
)
func migrate(ctx context.Context) (err error) {
var (
count int64
deptID string
roleID string
)
tx := db.DB().WithContext(ctx)
tx.Model(&models.Department{}).Count(&count)
if count <= 0 {
for _, v := range defaultDepartments {
deptModel := &models.Department{
Parent: "",
Name: v,
}
if err = tx.Save(deptModel).Error; err == nil {
if deptID == "" {
deptID = deptModel.ID
}
}
}
}
tx.Model(&models.Role{}).Count(&count)
if count <= 0 {
for _, v := range defaultRoles {
roleModel := &models.Role{
Name: v,
Permissions: "[]",
}
if err = tx.Save(roleModel).Error; err == nil {
if roleID == "" {
roleID = roleModel.ID
}
}
}
}
tx.Model(&models.User{}).Count(&count)
if count <= 0 {
for k, v := range defaultUsers {
tx.Save(&models.User{
UID: k,
Department: deptID,
Role: roleID,
Username: v,
Password: "Passwd#" + k,
Email: k + "@echo.me",
Avatar: "/profile/avatar/" + k,
Gender: "man",
})
}
}
return
}

View File

@ -7,6 +7,7 @@ import (
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/config"
"git.nobla.cn/golang/moto/models"
"git.nobla.cn/golang/moto/version"
"git.nobla.cn/golang/rest"
"git.nobla.cn/golang/rest/plugins/identity"
"git.nobla.cn/golang/rest/plugins/validate"
@ -35,10 +36,13 @@ func (svr *Server) prepare() (err error) {
if err = db.DB().AutoMigrate(item); err != nil {
return
}
if err = rest.AutoMigrate(svr.ctx, db.DB(), item, rest.WithoutDomain(), rest.WithModuleName("rest")); err != nil {
if err = rest.AutoMigrate(svr.ctx, db.DB(), item, rest.WithoutDomain(), rest.WithModuleName(version.ModuleName)); err != nil {
return
}
}
if err = migrate(svr.ctx); err != nil {
return
}
return
}

View File

@ -3,4 +3,5 @@ package version
var (
ProductName = "moto"
Version = "0.0.1"
ModuleName = "rest"
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MOTO</title>
<link rel="stylesheet" href="//at.alicdn.com/t/c/font_3832926_ya7vh14nje.css">
<script type="module" crossorigin src="/static/index-BGRRVEuU.js"></script>
<link rel="stylesheet" crossorigin href="/static/index-BhBbL-Pz.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -30,11 +30,11 @@ export function userLogout() {
}
export function updateProfile(data) {
return request.put('/organize/profile', data)
return request.put('/user/profile', data)
}
export function resetPassword(oldPwd, newPwd) {
return request.put('/organize/password', {
return request.put('/passport/reset-password', {
old_password: oldPwd,
new_password: newPwd
})
@ -53,22 +53,22 @@ export function getDepartmentUsers(id) {
}
export function getUsers(id) {
return request.get(`/organize/user-list`)
return request.get(`/organize/user-list`);
}
export function getClientHello() {
return request.post(`/organize/client-hello`)
return request.post(`/organize/client-hello`);
}
export function getUserAvatar(uid) {
return `${getBaseUrl()}/organize/avatar/${uid}`
return `${getBaseUrl()}/organize/avatar/${uid}`;
}
export function setUserAttr(name, value) {
let data = '';
if (typeof value !== 'string') {
try {
data = JSON.stringify(value)
data = JSON.stringify(value);
} catch (e) {
data = value
}

View File

@ -25,54 +25,6 @@ let statusMap = [
"value": "holiday",
"color": "#b3261e",
"retain": false
},
{
"label": "振铃",
"value": "ringing",
"color": "#e63757",
"retain": true
},
{
"label": "通话",
"value": "answer",
"color": "#b3261e",
"retain": true
},
{
"label": "静音",
"value": "muted",
"color": "#34AB62",
"retain": true
},
{
"label": "转接",
"value": "transfer",
"color": "#C8D541",
"retain": true
},
{
"label": "耳语",
"value": "whisper",
"color": "#43539D",
"retain": true
},
{
"label": "监听",
"value": "eavesdrop",
"color": "#0824F8",
"retain": true
},
{
"label": "未注册",
"value": "unavailable",
"color": "#bbbbbb",
"retain": true
},
{
"label": "话后",
"value": "wrapUp",
"color": "#E36B09",
"retain": true
}
]

View File

@ -426,6 +426,10 @@
}
}
.el-select {
width: var(--form-control-width);
}
.el-input {
width: var(--form-control-width);
@ -520,13 +524,14 @@ a {
.avatar-list {
margin-bottom: 1rem;
.el-avatar{
.el-avatar {
margin-right: .25rem;
margin-bottom: .25rem;
cursor: pointer;
}
}
.avatar-footer{
.avatar-footer {
text-align: right;
font-size: .8rem;
}

View File

@ -182,13 +182,23 @@ class CRUD {
resolve(this, this.schemas)
return
}
httpclicent.get(`/rest/schema/${this.modelName}/${this.tableName}`).then(res => {
this.schemas = res;
this.__prepare()
resolve(this, this.schemas);
}).catch(e => {
reject(e)
})
if (this.modelName != "") {
httpclicent.get(`/rest/schema/${this.modelName}/${this.tableName}`).then(res => {
this.schemas = res;
this.__prepare()
resolve(this, this.schemas);
}).catch(e => {
reject(e)
})
} else {
httpclicent.get(`/rest/schema/${this.tableName}`).then(res => {
this.schemas = res;
this.__prepare()
resolve(this, this.schemas);
}).catch(e => {
reject(e)
})
}
})
}

View File

@ -1,44 +0,0 @@
<template>
<span class="call-widget" @click="handleOffer">
{{ value }}
</span>
</template>
<script setup>
import { computed } from 'vue';
import useUserStore from '@/stores/user'
import bus from 'vue3-eventbus'
const props = defineProps({
number: {
type: String,
}
})
const value = computed(() => {
const { hiddlePhone } = useUserStore()
if (hiddlePhone) {
return invisible(props.number)
}
return props.number
})
const invisible = (s) => {
if (typeof s !== 'string') {
return s
}
if (s.length <= 5) {
return s
}
var reg = /^(\d{3})\d{4}(\d+)$/;
if (s.length <= 5) {
reg = /^(\d{2})\d{2}(\d+)$/;
}
return s.replace(reg, "$1****$2");
}
const handleOffer = (e) => {
bus.emit("sip_offer", { callee: props.number })
}
</script>

View File

@ -68,7 +68,7 @@ const menu = [
]
},
{
label: "座席管理",
label: "用户管理",
route: "/organize/users",
permissions: [
{

View File

@ -12,11 +12,6 @@
<li>
<icon name="fullscreen" @click="handleFullscreen"></icon>
</li>
<li>
<el-badge :value="unreadMsgCount" :hidden="unreadMsgCount <= 0">
<icon name="message" @click="handleListNotices"></icon>
</el-badge>
</li>
<li style="display: none;">
<el-dropdown @command="handleChangeLang">
<icon name="network"></icon>
@ -79,17 +74,17 @@ import Icon from '@/components/widgets/Icon.vue';
import { useRouter } from 'vue-router'
import { getUserStatus, getStatusText, getStatusTextColor } from '@/assets/js/status'
import { ElNotification } from 'element-plus';
import Notice from './parts/Notice.vue';
const systemStore = useSystemStore();
const themeStore = useThemeStore();
const userStore = useUserStore();
const userState = ref('idle');
const dialogVisible = ref(false)
const dialogVisible = ref(false);
const { logoUrl, productName } = storeToRefs(systemStore);
const { headerBackgroundColor } = storeToRefs(themeStore);
const { uid, avatar, username, unreadMsgCount } = storeToRefs(userStore);
const { avatar, username } = storeToRefs(userStore);
const logout = inject('logout');
@ -114,11 +109,6 @@ const handleFullscreen = (e) => {
}
}
const handleListNotices = (e) => {
let uid = userStore.uid;
router.push(`/organize/user/notice?receiver=${uid}&read=no`)
}
const handleChangeLang = (e) => {
systemStore.setLanguage(e)
}
@ -145,28 +135,7 @@ const handleMenuCommand = (e) => {
}
}
const refreshUnreadMsgCount = () => {
}
const doUserStatusChange = (e) => {
if (e.data) {
userState.value = e.data.state;
}
}
const doUserNotice = (e) => {
let data = e.data;
let notice = ElNotification({
title: data.title,
position: 'bottom-right',
message: h(Notice, { data: data, onDone: () => { notice.close() } })
})
refreshUnreadMsgCount();
}
onMounted(() => {
refreshUnreadMsgCount();
})
onUnmounted(() => {

View File

@ -54,7 +54,7 @@ const { productName, collapse, sidebarWidth, flowSidebarVisible } = storeToRefs(
const { backgroundColor, sidebarBackgroundColor } = storeToRefs(themeStore);
const loading = ref(false)
const loading = ref(true)
const router = useRouter()
@ -87,7 +87,6 @@ provide('logout', logout)
onMounted(() => {
document.title = productName.value
let schema = window.location.protocol == 'http:' ? "ws://" : 'wss://'
//
getConfigure().then(res => {
const { setAttributeValue } = useSystemStore();
@ -95,6 +94,14 @@ onMounted(() => {
let row = res[i];
setAttributeValue(row.attribute, row.value);
}
return getUserProfile();
}).then(res => {
const { setUsername, setIsAdmin, setPermissions, setAvatar } = useUserStore()
setIsAdmin(res.admin);
setUsername(res.username)
setAvatar(res.avatar)
setPermissions(res.permissions)
loading.value = false
}).catch(e => {
console.log(e);
router.push('/login');

View File

@ -1,345 +0,0 @@
<template>
<ul class="list list-inline">
<!--
<li class="hidden-sm-and-down">
<el-input :prefix-icon="Search" v-model="qs" clearable class="round"></el-input>
</li>
-->
<li>
<el-popover placement="bottom" :width="200" trigger="click">
<template #reference>
<icon name="phone"></icon>
</template>
<div class="dialplate" @keydown="handleKeypress">
<div class="dialplate-input">
<el-input v-model="number" placeholder="请输入要拨打的号码" readonly></el-input>
</div>
<div class="dialplate-clear">
<el-icon>
<Close v-if="number.length > 0" @click="handleClearInput"></Close>
</el-icon>
</div>
<div class="dialplate-button" @click="handleInput(item)" v-for="item in dialNumbers">{{ item }}
</div>
<div class="dialplate-actions">
<el-button type="success" circle @click="handleOffer">
<icon name="phone"></icon>
</el-button>
</div>
</div>
</el-popover>
</li>
<li v-if="inWrapUp">
<el-tooltip content="结束整理">
<icon name="wrapup" class="text-danger" @click="handleEndWraup"></icon>
</el-tooltip>
</li>
<li v-if="inCalling">
<el-tooltip content="保持">
<icon name="muted" class="text-warning" @click="handleToggleHold"></icon>
</el-tooltip>
</li>
<li v-if="canHanguped">
<el-tooltip content="挂机">
<icon name="phone" class="text-danger" @click="handleHangup"></icon>
</el-tooltip>
</li>
</ul>
<Teleport to="body">
<popup v-model="dialogVisible" :model="call"></popup>
</Teleport>
</template>
<script setup>
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
import Connection from '@/assets/js/connection'
import Popup from './Popup.vue'
import Sip from '@/assets/js/sip'
import useUserStore from '@/stores/user'
import Icon from '@/components/widgets/Icon.vue'
import { Search } from '@element-plus/icons-vue'
import { nextID, hangup, endWrapUp } from '@/apis/control'
import { ElMessage } from 'element-plus'
import { Close } from '@element-plus/icons-vue'
import { getBaseHost } from '@/apis/request'
import bus from 'vue3-eventbus'
const dialogVisible = ref(false)
const call = ref({
id: '',
direction: '',
caller: '',
callee: '',
province: '',
city: '',
status: 'hanguped',
modtime: 0,
})
const qs = ref('')
const number = ref('')
const inWrapUp = ref(false);
const inCalling = computed(() => {
return call.value.status === 'answered'
})
const canHanguped = computed(() => {
return call.value.status !== 'hanguped'
})
const dialNumbers = computed(() => {
return ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
})
/**
* 建立一个呼叫
* @param {String} caller
* @param {String} callee
* @param {String} direction
*/
const setupCall = (caller, callee, direction) => {
call.value.caller = caller;
call.value.callee = callee;
call.value.direction = direction;
call.value.modtime = (new Date()).getTime() / 1000;
call.value.status = 'setup';
if (!dialogVisible.value) {
dialogVisible.value = true;
}
}
/**
* 更新一个呼叫
* @param {String} status
*/
const updateCall = (status) => {
call.value.status = status;
call.value.modtime = (new Date()).getTime() / 1000;
if (status === 'hanguped') {
call.value.id = '';
if (dialogVisible.value) {
dialogVisible.value = false;
}
}
}
const updateCallStatus = (incall) => {
let appElement = document.getElementById('app');
if (appElement) {
if (incall) {
appElement.className = 'call-answering'
} else {
appElement.className = ''
}
}
}
const handleKeypress = (e) => {
if (dialNumbers.value.indexOf(e.key) > -1) {
number.value += e.key
if (call.value.status === 'answered') {
Sip.sendDTMF(e.key).then(res => { }).catch(e => { })
}
} else {
if (e.keyCode === 8) {
if (number.value.length > 0) {
number.value = number.value.substring(0, number.value.length - 1);
}
}
}
}
const handleInput = (e) => {
number.value += e
if (call.value.status === 'answered') {
Sip.sendDTMF(e).then(res => { }).catch(e => { })
}
}
const handleClearInput = (e) => {
if (number.value.length > 0) {
number.value = number.value.substring(0, number.value.length - 1);
}
}
const handleCallEvent = e => {
// sip
// let data = e.data;
// switch (e.event) {
// case 'CALL_CREATE':
// call.value.id = data.id;
// call.value.caller = data.caller;
// call.value.callee = data.callee;
// call.value.direction = data.direction;
// call.value.province = data.province;
// call.value.city = data.city;
// call.value.status = 'setup';
// call.value.modtime = (new Date()).getTime() / 1000;
// if (!data.forwarded) {
// if (!dialogVisible.value) {
// dialogVisible.value = true;
// }
// }
// break;
// case 'CALL_BRIDGE':
// updateCall('answered');
// updateCallStatus(true);
// break;
// case 'CALL_HANGUP':
// updateCall('hanguped');
// updateCallStatus(false);
// break;
// }
}
const handleStateEvent = e => {
if (!e.data) {
return;
}
if (e.data.state === 'wrapUp') {
inWrapUp.value = true;
} else {
inWrapUp.value = false;
}
}
const sipOffer = (callee) => {
const { uid } = useUserStore()
nextID().then(res => {
Sip.offer(callee, { 'sip_h_X-call_id': res.id }).then(res => {
setupCall(uid, callee, 'outbound');
ElMessage.success('发起成功')
number.value = '';
}).catch(e => {
ElMessage.error(e.message);
})
}).catch(e => {
Sip.offer(callee).then(res => {
setupCall(uid, callee, 'outbound');
ElMessage.success('发起成功')
number.value = '';
}).catch(e => {
ElMessage.error(e.message);
})
})
}
provide('sipOffer', sipOffer);
const handleOffer = e => {
const callee = number.value;
sipOffer(callee);
}
const handleEndWraup = e => {
const { uid } = useUserStore()
endWrapUp(uid).then(res => {
}).catch(e => {
ElMessage.error(e.message)
})
}
const handleToggleHold = e => {
}
const handleHangup = e => {
const { uid } = useUserStore()
if (call.value.status === 'setup' && call.value.direction === 'inbound' && Sip.getIsRegistered()) {
Sip.decline().then(res => {
}).catch(e => {
ElMessage.error(e.message)
})
} else {
if (Sip.getIsRegistered()) {
Sip.hangup().then(res => {
}).catch(e => {
ElMessage.error(e.message)
})
} else {
hangup(uid).then(res => {
}).catch(e => {
ElMessage.error(e.message)
})
}
}
}
onMounted(() => {
const { uid, getPassword, getAccessToken, setDeviceRegisterState, getAutoLoginDevice } = useUserStore()
let domain = 'test.cc.echo.me';
if (process.env.NODE_ENV === 'production') {
domain = window.location.hostname;
}
if (!getAutoLoginDevice()) {
return;
}
//
Sip.connect(getBaseHost(), {
domain: domain,
urlPath: '/sip',
debug: false,
video: false,
qs: {
'access_token': getAccessToken(),
}
})
Sip.register(uid, getPassword()).then(res => {
}).catch(e => {
console.log(e);
})
Sip.on('EVENT_DEVICE_REGISTERED', (e) => {
setDeviceRegisterState(true)
})
Sip.on('EVENT_DEVICE_UNREGISTERED', (e) => {
setDeviceRegisterState(false)
})
Sip.on('EVENT_CALL_RECEIVED', (e) => {
setupCall(e.caller, e.callee, e.direction);
})
Sip.on('EVENT_CALL_ANSWER', e => {
if (!call.value.id) {
number.value = '';
updateCall('answered');
}
})
Sip.on('EVENT_CALL_HANGUP', e => {
if (!call.value.id) {
number.value = '';
updateCall('hanguped');
}
})
bus.on("sip_offer", e => {
if (typeof (e) === 'object') {
if (e.callee && typeof e.callee === 'string') {
sipOffer(e.callee);
}
}
})
Connection.on('call_event', handleCallEvent)
Connection.on('user_state', handleStateEvent)
})
onUnmounted(() => {
Connection.off('call_event', handleCallEvent)
Connection.on('user_state', handleStateEvent)
})
</script>

View File

@ -1,30 +0,0 @@
<template>
<div class="notice-container">
<div class="notice-body">
{{ data.content }}
</div>
<div class="notice-footer">
<el-button type="primary" @click="handleReadMsg"></el-button>
</div>
</div>
</template>
<script setup>
import { ElMessage } from 'element-plus';
const props = defineProps({
data: {
type: Object,
default: () => {
return {}
}
}
})
const emit = defineEmits(['done'])
const handleReadMsg = (e) => {
}
</script>

View File

@ -1,229 +0,0 @@
<template>
<div class="popup-dialog" :style="{ left: position.left + 'px', top: position.top + 'px' }" v-if="modelValue"
:class="needAnswer ? 'noanswer' : ''">
<div class="popup-header" @mousedown="handleDragStart">
<span class="text-muted cursor-pointer" @click="handleClose">×</span>
</div>
<div class="popup-container">
<div class="popup-avatar">
<el-avatar :size="64" :src="avatar">
<img src="data:image/png;base64,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">
</el-avatar>
<h3>{{ displayNumber }}</h3>
</div>
<div class="popup-content">
<div class="popup-info">
<span>{{ model.province }}</span> <span>{{ model.city }}</span>
</div>
<time class="popup-timer text-muted"> {{ duration }}</time>
</div>
<div class="popup-actions">
<el-button type="success" :disable="answering" v-if="needAnswer" size="large" circle title="接听">
<icon name="phone" @click="handleAnswer"></icon>
</el-button>
<el-button type="warning" :disable="holding" v-if="inCalling" size="large" circle :title="holdLabel">
<icon :name="isHold ? 'unmuted' : 'muted'" @click="handleToggleHold"></icon>
</el-button>
<el-button type="danger" :disable="hanguping" size="large" circle title="挂断">
<icon name="phone" @click="handleHangup"></icon>
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { hold, unhold, hangup } from '@/apis/control'
import useUserStore from '@/stores/user'
import { durationFormat } from '@/assets/js/formatter'
import Icon from '@/components/widgets/Icon.vue';
import Sip from '@/assets/js/sip'
import { ElMessage } from 'element-plus';
import { getUserAvatar, getUserInfo } from '@/apis/organize';
const props = defineProps({
model: {
type: Object,
},
modelValue: {
type: Boolean,
default: false
}
})
const isHold = ref(false);
const duration = ref('');
const answering = ref(false);
const holding = ref(false);
const hanguping = ref(false);
const dragging = ref(false);
const avatar = ref('');
const position = ref({ left: 0, top: 0 });
const prevDragPosition = ref({ x: 0, y: 0 });
const emit = defineEmits(['update:modelValue']);
const needAnswer = computed(() => {
if (!Sip.getIsRegistered()) {
return false;
}
if (props.model.status !== 'setup') {
return false;
}
if (props.model.direction === 'inbound') {
return true;
}
const { uid } = useUserStore();
if (props.model.direction === 'inner' && props.model.callee === uid) {
return true;
}
return false;
})
const inCalling = computed(() => {
return props.model.status === 'answered';
})
const holdLabel = computed(() => {
return isHold.value ? '接回' : '保持';
})
const displayNumber = ref('');
const timerTicket = () => {
if (props.model.modtime > 0) {
duration.value = durationFormat(((new Date()).getTime() / 1000) - props.model.modtime, ':');
}
setTimeout(timerTicket, 1000);
}
const handleClose = (e) => {
avatar.value = '';
emit('update:modelValue', false);
}
const handleAnswer = (e) => {
answering.value = true;
Sip.answer().then(res => {
answering.value = false;
}).catch(e => {
ElMessage.error(e.message)
answering.value = false;
})
}
const handleToggleHold = (e) => {
const { uid } = useUserStore();
holding.value = true
if (isHold.value) {
unhold(uid).then(res => {
holding.value = false
isHold.value = false
}).catch(e => {
holding.value
ElMessage.error(e.message)
})
} else {
hold(uid).then(res => {
holding.value
isHold.value = true
}).catch(e => {
holding.value
ElMessage.error(e.message)
})
}
}
const handleHangup = (e) => {
const { uid } = useUserStore()
hanguping.value = true;
if (props.model.status === 'setup' && props.model.direction === 'inbound' && Sip.getIsRegistered()) {
Sip.decline().then(res => {
hanguping.value = false
}).catch(e => {
hanguping.value = false
ElMessage.error(e.message)
})
} else {
if (Sip.getIsRegistered()) {
Sip.hangup().then(res => {
hanguping.value = false
}).catch(e => {
hanguping.value = false
ElMessage.error(e.message)
})
} else {
hangup(uid).then(res => {
hanguping.value = false
}).catch(e => {
hanguping.value = false
ElMessage.error(e.message)
})
}
}
}
const handleDragStart = (e) => {
dragging.value = true
prevDragPosition.value.x = e.clientX
prevDragPosition.value.y = e.clientY
}
const handleDragging = (e) => {
if (!dragging.value) {
return
}
let x = e.clientX - prevDragPosition.value.x;
let y = e.clientY - prevDragPosition.value.y;
prevDragPosition.value.x = e.clientX;
prevDragPosition.value.y = e.clientY;
position.value.left += x;
position.value.top += y;
}
const handleDragged = (e) => {
dragging.value = false
}
onMounted(() => {
position.value.left = (window.innerWidth - 280) / 2
position.value.top = window.innerHeight / 10
document.addEventListener('mousemove', handleDragging)
document.addEventListener('mouseup', handleDragged)
const { uid } = useUserStore()
watch(() => { return props.model }, (val) => {
if (val.direction === 'inbound') {
displayNumber.value = val.caller;
} else if (val.direction === 'outbound') {
displayNumber.value = val.callee;
} else if (val.direction === 'inner') {
if (val.caller === uid) {
displayNumber.value = val.callee;
avatar.value = getUserAvatar(val.callee);
} else {
displayNumber.value = val.caller;
avatar.value = getUserAvatar(val.caller);
}
getUserInfo(displayNumber.value).then(res => {
displayNumber.value = `${res.username}(${res.id})`
})
}
}, { deep: true })
timerTicket();
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleDragging)
document.removeEventListener('mouseup', handleDragged)
})
</script>

View File

@ -10,7 +10,7 @@ const useSystemStore = defineStore('system', {
lang: 'zh-CN',
logoUrl: '//s3.tebi.io/tenos/images/logo/jc.png',
copyright: '2005-2023 JUSTCALL 版权 © 2023 集时股份呼叫中心开发团队',
productName: '呼叫中心平台',
productName: '在线系统',
variables: {},
}
},

View File

@ -11,6 +11,7 @@ const useUserStore = defineStore('user', {
pwdhash: '',
accessToken: '*',
tokenExpiredAt: 0,
isAdmin: false,
permissions: [],
description: '',
loginDevice: false,
@ -56,6 +57,9 @@ const useUserStore = defineStore('user', {
setDeviceRegisterState(ok) {
this.deviceRegistered = ok
},
setIsAdmin(b) {
this.isAdmin = b;
},
setPermissions(s) {
if (typeof s === 'string') {
try {
@ -98,6 +102,9 @@ const useUserStore = defineStore('user', {
},
hasPermission(permission) {
let ret = false
if (this.isAdmin) {
return true;
}
if (typeof permission === 'boolean') {
ret = permission
} else if (Array.isArray(permission)) {

View File

@ -64,8 +64,9 @@ const submit = () => {
}
loading.value = true;
userLogin(model.value.username, model.value.password).then(res => {
const { setUserID, setAccessToken, setPassword, setUsername, setPermissions, setAvatar, setAutoLoginDevice } = useUserStore()
const { setUserID, setIsAdmin, setAccessToken, setPassword, setUsername, setPermissions, setAvatar, setAutoLoginDevice } = useUserStore()
setUserID(res.uid);
setIsAdmin(res.admin);
setAccessToken(res.token, res.expire_in);
setPassword(model.value.password);
setAutoLoginDevice(model.value.remember);

View File

@ -1,6 +1,6 @@
<template>
<viewer :title="title" :module-name="moduleName" :table-name="tableName" :disable-toolbar="false"
defaultSortable="id" formMode="drawer" :disablePermission="true">
defaultSortable="id" formMode="drawer">
<template #crudform="{ schema, model }">
<el-tree ref="treeElement" :data="permissions" v-if="schema.column == 'permissions'" node-key="id"
v-model="model.permissions" :props="treeOptions" @check-change="handleTreeChange(model)"

View File

@ -21,46 +21,47 @@
</el-col>
<el-col :span="18">
<div class="profile-tabs">
<el-tabs v-model="activeTab">
<el-tab-pane label="基本资料" name="basic">
<el-form ref="basicForm" :model="basicModel" status-icon label-width="120px" class="segment-form">
<el-form-item label="用户名" prop="oldPassword">
<el-input v-model="basicModel.username" autocomplete="off" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="newPassword">
<el-input v-model="basicModel.email" autocomplete="off" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="个人简介" prop="confirmPassword">
<el-input v-model="basicModel.description" type="textarea" autocomplete="off" rows="10"
placeholder="请介绍下自己" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleUpdateProfile"></el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="密码修改" name="password">
<el-form ref="passwordForm" :model="passwordModel" :rules="passwordRules" status-icon
label-width="120px" class="segment-form">
<el-form-item label="旧密码" prop="oldPassword">
<el-input v-model="passwordModel.oldPassword" type="password" autocomplete="off"
placeholder="请输入旧密码" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordModel.newPassword" type="password" autocomplete="off"
placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordModel.confirmPassword" type="password" autocomplete="off"
placeholder="请再次输入新密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleResetPassword"></el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="基本资料" name="basic">
<el-form ref="basicForm" :model="basicModel" status-icon label-width="120px"
class="segment-form">
<el-form-item label="用户名" prop="oldPassword">
<el-input v-model="basicModel.username" autocomplete="off" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱" prop="newPassword">
<el-input v-model="basicModel.email" autocomplete="off" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="个人简介" prop="confirmPassword">
<el-input v-model="basicModel.description" type="textarea" autocomplete="off"
rows="10" placeholder="请介绍下自己" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleUpdateProfile"></el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="密码修改" name="password">
<el-form ref="passwordForm" :model="passwordModel" :rules="passwordRules" status-icon
label-width="120px" class="segment-form">
<el-form-item label="旧密码" prop="oldPassword">
<el-input v-model="passwordModel.oldPassword" type="password" autocomplete="off"
placeholder="请输入旧密码" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordModel.newPassword" type="password" autocomplete="off"
placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordModel.confirmPassword" type="password" autocomplete="off"
placeholder="请再次输入新密码" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleResetPassword"></el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</el-col>
</el-row>
</div>
@ -75,7 +76,7 @@
box-shadow: var(--el-box-shadow-light);
}
.profile-tabs{
.profile-tabs {
background-color: white;
border-radius: 6px;
box-shadow: var(--el-box-shadow-light);

View File

@ -15,4 +15,15 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
build: {
outDir: 'release',
assetsDir: 'static',
rollupOptions: {
output: {
manualChunks(id) {
return 'notebook'
}
}
}
}
})