提交基础框架

This commit is contained in:
fancl 2024-12-12 16:24:50 +08:00
parent ed42db709f
commit 0d4039cf34
85 changed files with 13758 additions and 27 deletions

50
api.go
View File

@ -1,5 +1,53 @@
package moto
func (svr *Server) routes() {
import (
"git.nobla.cn/golang/kos"
"git.nobla.cn/golang/kos/entry/http"
"git.nobla.cn/golang/moto/internal/user"
"git.nobla.cn/golang/moto/internal/user/passport"
"git.nobla.cn/golang/moto/internal/user/types"
)
func (svr *Server) handleLogin(ctx *http.Context) (err error) {
var (
tk *types.Tokenize
req *passport.LoginRequest
)
req = &passport.LoginRequest{}
if err = ctx.Bind(req); err != nil {
return ctx.Error(http.ErrInvalidPayload, err.Error())
}
if tk, err = passport.Login(ctx.Context(), req); err != nil {
return ctx.Error(http.ErrPermissionDenied, err.Error())
}
return ctx.Success(tk)
}
func (svr *Server) handleLogout(ctx *http.Context) (err error) {
passport.Logout(ctx.Context(), ctx.User().Get("token"))
return ctx.Success("logout")
}
func (svr *Server) handleProfile(ctx *http.Context) (err error) {
var (
profile *types.Profile
)
if profile, err = user.Profile(ctx.Context(), ctx.User().ID); err != nil {
return ctx.Error(http.ErrTemporaryUnavailable, err.Error())
}
return ctx.Success(profile)
}
func (svr *Server) handleGetConfigure(ctx *http.Context) (err error) {
return ctx.Success(map[string]string{})
}
func (svr *Server) routes() {
kos.Http().Use(user.AuthMiddleware)
user.AllowUri("/passport/login")
kos.Http().Handle(http.MethodPost, "/passport/login", svr.handleLogin)
kos.Http().Handle(http.MethodDelete, "/passport/logout", svr.handleLogout)
kos.Http().Handle(http.MethodGet, "/user/profile", svr.handleProfile)
kos.Http().Handle(http.MethodGet, "/user/configures", svr.handleGetConfigure)
}

11
go.mod
View File

@ -6,6 +6,8 @@ require (
git.nobla.cn/golang/kos v0.1.32
git.nobla.cn/golang/rest v0.0.1
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/mssola/useragent v1.0.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.25.12
@ -13,21 +15,14 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/peterh/liner v1.2.2 // indirect
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/net v0.21.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

22
go.sum
View File

@ -7,19 +7,11 @@ git.nobla.cn/golang/rest v0.0.1/go.mod h1:tGDOul2GGJtxk6fAeu+YLpMt/Up/TsBonTkygy
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/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=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
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/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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@ -28,10 +20,10 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mssola/useragent v1.0.0 h1:WRlDpXyxHDNfvZaPEut5Biveq86Ze4o4EMffyMxmH5o=
github.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNHnpU+b85Y=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
@ -40,16 +32,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
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/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=

View File

@ -0,0 +1,80 @@
package user
import (
"git.nobla.cn/golang/kos/entry/http"
"git.nobla.cn/golang/moto/internal/user/passport"
"git.nobla.cn/golang/moto/internal/user/types"
"os"
"strings"
)
var (
allowUris []string
CookieName = "MOTO_US"
)
func init() {
allowUris = make([]string, 0, 10)
}
func AllowUri(s string) {
allowUris = append(allowUris, s)
}
func isAllowed(uriPath string) bool {
for _, s := range allowUris {
sl := len(s)
if sl <= 0 {
continue
}
if s[sl-1] == '*' {
if strings.HasPrefix(uriPath, s[:sl-1]) {
return true
}
} else if s == uriPath {
return true
}
}
return false
}
func AuthMiddleware(next http.HandleFunc) http.HandleFunc {
return func(ctx *http.Context) (err error) {
if isAllowed(ctx.Request().URL.Path) {
return next(ctx)
}
var (
pos int
accessToken string
ui *http.Userinfo
tk *types.Tokenize
)
if accessToken = ctx.Query("access_token"); accessToken != "" {
goto __end
}
if accessToken = ctx.GetCookieValue(CookieName); accessToken != "" {
goto __end
}
if accessToken = ctx.Request().Header.Get("Authorization"); accessToken != "" {
goto __end
}
__end:
accessToken = strings.TrimSpace(accessToken)
if pos = strings.IndexByte(accessToken, ' '); pos > -1 {
accessToken = accessToken[pos+1:]
}
if tk, err = passport.Validate(ctx.Context(), accessToken); err != nil {
err = ctx.Error(http.ErrAccessDenied, "access denied")
err = os.ErrPermission
return
}
ui = &http.Userinfo{
ID: tk.UID,
Name: tk.Name,
}
ui.Set("token", tk.Token)
ctx.SetUser(ui)
return next(ctx)
}
}

View File

@ -0,0 +1,108 @@
package passport
import (
"context"
"fmt"
"git.nobla.cn/golang/kos/pkg/cache"
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/internal/user/types"
"git.nobla.cn/golang/moto/models"
"github.com/mssola/useragent"
"strings"
"sync"
"time"
)
var (
tokenizeLocker sync.RWMutex
tokenizes map[string]*types.Tokenize
LoginMaxTires = 5
)
func init() {
tokenizes = make(map[string]*types.Tokenize)
}
func Login(ctx context.Context, req *LoginRequest) (tk *types.Tokenize, err error) {
var (
tires int
duration time.Duration
)
tx := db.WithContext(ctx)
userModel := &models.User{}
if req.Remember {
duration = time.Hour * 24
} else {
duration = time.Hour * 2
}
cacheKey := fmt.Sprintf("passport:login:%s:attempts", req.RealIP)
if LoginMaxTires > 0 {
if err = cache.Load(ctx, cacheKey, &tires); err == nil {
if tires > LoginMaxTires {
err = ErrTooManyAttempts
return
}
}
}
if err = tx.Where("uid=?", req.Username).First(userModel).Error; err == nil {
if userModel.Password == req.Password || md5Hash(req.Password) == userModel.Password {
tk = types.NewTokenize(userModel.UID, duration)
tokenizeLocker.Lock()
tokenizes[tk.Token] = tk
tokenizeLocker.Unlock()
tk.Name = userModel.Username
ua := useragent.New(req.UserAgent)
loginModel := &models.Login{
UID: userModel.UID,
IP: req.RealIP,
Os: ua.OS(),
Platform: ua.Platform(),
AccessToken: tk.Token,
UserAgent: req.UserAgent,
}
browser, browserVersion := ua.Browser()
loginModel.Browser = browser + "/" + browserVersion
tx.Save(loginModel)
} else {
err = ErrInvalidPassword
_ = cache.StoreEx(ctx, cacheKey, tires+1, time.Minute*10)
}
} else {
_ = cache.StoreEx(ctx, cacheKey, tires+1, time.Minute*10)
}
return
}
func Validate(ctx context.Context, token string) (tk *types.Tokenize, err error) {
var (
ok bool
)
tokenizeLocker.Lock()
defer tokenizeLocker.Unlock()
token = strings.TrimSpace(token)
if token == "" {
return nil, ErrPermissionDenied
}
if tk, ok = tokenizes[token]; !ok {
return nil, ErrPermissionDenied
} else {
if tk.Validate() {
return tk, nil
} else {
delete(tokenizes, token)
return nil, ErrPermissionDenied
}
}
return
}
func Logout(ctx context.Context, token string) (ok bool) {
tokenizeLocker.Lock()
if _, ok = tokenizes[token]; ok {
delete(tokenizes, token)
}
tokenizeLocker.Unlock()
return
}

View File

@ -0,0 +1,19 @@
package passport
import (
"errors"
)
var (
ErrPermissionDenied = errors.New("permission denied")
ErrInvalidPassword = errors.New("invalid password")
ErrTooManyAttempts = errors.New("too many attempts")
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Remember bool `json:"remember"`
RealIP string `json:"real_ip"`
UserAgent string `json:"user_agent"`
}

View File

@ -0,0 +1,13 @@
package passport
import (
"crypto/md5"
"encoding/hex"
"git.nobla.cn/golang/kos/util/bs"
)
func md5Hash(s string) string {
hash := md5.New()
hash.Write(bs.StringToBytes(s))
return hex.EncodeToString(hash.Sum(nil))
}

View File

@ -0,0 +1,17 @@
package user
import (
"context"
"git.nobla.cn/golang/moto/common/db"
"git.nobla.cn/golang/moto/internal/user/types"
)
func Profile(ctx context.Context, uid string) (profile *types.Profile, err error) {
profile = &types.Profile{}
tx := db.WithContext(ctx)
err = tx.Table("users AS u").
Select("u.uid as id", "u.username", "u.avatar", "u.email", "u.description", "u.role", "r.permissions").
Joins("left join roles AS r on r.id = u.role").Where("u.uid=? ", uid).
First(profile).Error
return
}

View File

@ -0,0 +1,13 @@
package types
type (
Profile struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Description string `json:"description"`
Permissions string `json:"permissions"`
}
)

View File

@ -0,0 +1,29 @@
package types
import (
"git.nobla.cn/golang/kos/util/random"
"github.com/google/uuid"
"strings"
"time"
)
type Tokenize struct {
UID string `json:"uid"`
Name string `json:"name"`
Token string `json:"token"`
ExpireIn int `json:"expire_in"`
ExpiredAt time.Time `json:"-"`
}
func (tk *Tokenize) Validate() bool {
return time.Now().Before(tk.ExpiredAt)
}
func NewTokenize(uid string, duration time.Duration) *Tokenize {
return &Tokenize{
UID: uid,
Token: random.String(32) + strings.ReplaceAll(uuid.New().String(), "-", ""),
ExpireIn: int(duration / time.Second),
ExpiredAt: time.Now().Add(duration),
}
}

18
models/login.go 100644
View File

@ -0,0 +1,18 @@
package models
type Login struct {
ID string `json:"id" gorm:"primaryKey;size:20" comment:"ID" scenarios:"view;export"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime" comment:"登录时间" scenarios:"list;search;view;export" position:"2"`
Domain string `json:"domain" gorm:"index;size:60;not null;default:'default'" comment:"域" scenarios:"export"`
UID string `json:"uid" gorm:"column:uid;index;size:20;not null;default:''" props:"match:exactly" comment:"座席" format:"user" scenarios:"search;list;export" position:"1"`
IP string `json:"ip" gorm:"size:20;not null;default:''" comment:"IP" scenarios:"search;list;export"`
Browser string `json:"browser" gorm:"size:200;not null;default:''" comment:"浏览器" scenarios:"search;list;export"`
Os string `json:"os" gorm:"size:200;not null;default:''" comment:"操作系统" scenarios:"search;list;export"`
Platform string `json:"platform" gorm:"size:200;not null;default:''" comment:"平台" scenarios:"search;list;export"`
AccessToken string `json:"access_token" gorm:"size:200;not null;default:''" comment:"访问令牌" scenarios:"export"`
UserAgent string `json:"user_agent" gorm:"size:1024;not null;default:''" comment:"UserAgent" scenarios:"export"`
}
func (model *Login) TableName() string {
return "user_logins"
}

View File

@ -26,6 +26,7 @@ func (svr *Server) prepare() (err error) {
values := []any{
&models.User{},
&models.Role{},
&models.Login{},
&models.Department{},
}
for _, item := range values {

30
web/.gitignore vendored 100644
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

29
web/README.md 100644
View File

@ -0,0 +1,29 @@
# web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

17
web/index.html 100644
View File

@ -0,0 +1,17 @@
<!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">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

4631
web/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

33
web/package.json 100644
View File

@ -0,0 +1,33 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"color": "^4.2.3",
"dayjs": "^1.11.13",
"element-plus": "^2.9.0",
"normalize.css": "^8.0.1",
"pinia": "^2.2.6",
"pinia-plugin-persistedstate": "^4.1.3",
"pluralize": "^8.0.0",
"query-string": "^9.1.1",
"sass": "^1.82.0",
"screenfull": "^6.0.2",
"vue": "^3.5.13",
"vue-router": "^4.4.5",
"vue3-puzzle-vcode": "^1.1.7"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

47
web/src/App.vue 100644
View File

@ -0,0 +1,47 @@
<template>
<el-config-provider :locale="locale">
<RouterView />
</el-config-provider>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, provide } from 'vue';
import { RouterView, useRouter } from 'vue-router'
import zhCN from 'element-plus/dist/locale/zh-cn.mjs'
import enUS from 'element-plus/dist/locale/en.mjs'
import useSystemStore from '@/stores/system'
import useUserStore from '@/stores/user'
import { storeToRefs } from 'pinia';
const systemStore = useSystemStore()
const userStore = useUserStore()
const router = useRouter()
const locale = computed(() => {
const { lang } = storeToRefs(systemStore)
return lang.value == 'zh-CN' ? zhCN : enUS
})
onMounted(() => {
const { lang } = storeToRefs(systemStore)
if (lang.value === '') {
systemStore.setLanguage(navigator.language || navigator.userLanguage)
}
router.beforeEach((to, from, next) => {
if (to.meta && to.meta.allowGuest) {
next();
} else {
if (userStore.isAuthorization()) {
systemStore.closeFlowSidebar()
next()
} else {
next('/login');
}
}
})
})
</script>

View File

@ -0,0 +1,117 @@
import request, { getBaseUrl } from './request'
export function getUserProfile() {
return request.get(`/user/profile`)
}
export function getUserInfo(uid) {
return request.get(`/user/info/${uid}`)
}
export function userLogin(username, password) {
let domain = 'test.cc.echo.me';
if (process.env.NODE_ENV === 'production') {
domain = window.location.hostname;
}
return request.post(`/passport/login`, {
"domain": domain,
"username": username,
"password": password,
"remember": true
})
}
export function getUserTypes() {
return request.get('/organize/user-types')
}
export function userLogout() {
return request.delete('/passport/logout')
}
export function updateProfile(data) {
return request.put('/organize/profile', data)
}
export function resetPassword(oldPwd, newPwd) {
return request.put('/organize/password', {
old_password: oldPwd,
new_password: newPwd
})
}
export function lastLogin() {
return request.get('/organize/last-login');
}
export function getNestedDepartments() {
return request.get('/organize/department-nested')
}
export function getDepartmentUsers(id) {
return request.get(`/organize/department-users/${id}`)
}
export function getUsers(id) {
return request.get(`/organize/user-list`)
}
export function getClientHello() {
return request.post(`/organize/client-hello`)
}
export function getUserAvatar(uid) {
return `${getBaseUrl()}/organize/avatar/${uid}`
}
export function setUserAttr(name, value) {
let data = '';
if (typeof value !== 'string') {
try {
data = JSON.stringify(value)
} catch (e) {
data = value
}
} else {
data = value
}
return request.put(`/organize/user-variable`, {
name: name,
value: data,
})
}
export function getUserAttr(name) {
return new Promise((resolve, reject) => {
request.get(`/organize/user-variable/${name}`).then(res => {
if (res.value) {
try {
let data = JSON.parse(res.value);
resolve(data);
} catch (e) {
resolve(res.value)
}
} else {
reject(new Error("not found"))
}
}).catch(e => {
reject(e);
})
})
}
export function getUnreadMsgCount() {
return request.get(`/organize/unread/count`)
}
export function readUserMsg(id) {
return request.put(`/organize/notice-read/${id}`)
}
export function getDepartmentUserNested() {
return request.put(`/organize/department-users`)
}
export function getStatusTypes() {
return request.post(`/organize/status-types`)
}

View File

@ -0,0 +1,63 @@
import axios from "axios";
const client = axios.create({
baseURL: getBaseUrl()
});
export function getBaseHost() {
if (process.env.NODE_ENV === 'production') {
return location.host;
} else {
return 'api-dev.echo.me'
}
}
export function getBaseUrl() {
let host = getBaseHost();
if (process.env.NODE_ENV === 'production') {
return location.protocol + '//' + host;
} else {
return location.protocol + '//' + host
}
}
class HttpError extends Error {
constructor(message, code) {
super(message)
this.code = code
this.name = 'HttpError'
}
}
client.interceptors.request.use(req => {
let accessToken = sessionStorage.getItem('G_PSID1');
if (accessToken) {
req.headers.Authorization = accessToken;
}
return req;
})
client.interceptors.response.use(res => {
if (res.status != 200) {
return Promise.reject(new Error(`http response ${res.status}:${res.statusText}`))
}
if (typeof res.data === 'object' && res.data.hasOwnProperty('code')) {
if (res.data['code'] === 0) {
return res.data['data']
} else {
return Promise.reject(new HttpError(res.data['reason'], res.data['code']))
}
}
if (typeof res.data === 'object' && res.data.hasOwnProperty('errno')) {
if (res.data['errno'] == 0) {
return res.data['result']
} else {
return Promise.reject(new HttpError(res.data['errmsg'], res.data['errno']))
}
}
return res;
})
export default client;

View File

@ -0,0 +1,15 @@
import request from './request'
/**
* 连接到指定的endpoint
* @param {String} e
* @returns
*/
export function connectEndpoint(e) {
return request.put(`/endpoint/connect/${e}`)
}
export function getConfigure() {
return request.get(`/user/configures`)
}

View File

@ -0,0 +1,23 @@
export function deepCopy(v1, v2, ps) {
for (let k in v2) {
if (ps && typeof ps === 'object' && Array.isArray(ps)) {
if (ps.indexOf(k) == -1) {
continue
}
}
let v = v2[k];
if (typeof v === 'object') {
if (!v1[k]) {
v1[k] = Array.isArray(v) ? [] : {};
}
if (typeof v1[k] !== 'object') {
continue
}
v1[k] = deepCopy(v1[k], v)
} else {
v1[k] = v
}
}
return v1
}

View File

@ -0,0 +1,61 @@
export function durationFormat(duration, sep = null) {
duration = parseInt(duration);
if (isNaN(duration)) {
return '';
}
var hour = parseInt(duration / 3600);
var r = duration % 3600;
var min = parseInt(r / 60);
var sec = parseInt(r % 60);
hour = isNaN(hour) ? 0 : hour;
min = isNaN(min) ? 0 : min;
sec = isNaN(sec) ? 0 : sec;
if (sep) {
hour = hour > 9 ? hour : '0' + hour
min = min > 9 ? min : '0' + min
sec = sec > 9 ? sec : '0' + sec
return hour + sep + min + sep + sec;
} else {
let s = '';
if (hour > 0) {
s += hour + "h"
}
if (min > 0 || s != '') {
s += min + "m"
}
s += sec + "s";
return s;
}
}
export function monryFormat(number, decimals = 2, decPoint = '.', thousandsSep = ',') {
number = (number + '').replace(/[^0-9+-Ee.]/g, '')
let n = !isFinite(+number) ? 0 : +number
let prec = !isFinite(+decimals) ? 0 : Math.abs(decimals)
let sep = (typeof thousandsSep === 'undefined') ? ',' : thousandsSep
let dec = (typeof decPoint === 'undefined') ? '.' : decPoint
let s = ''
let toFixedFix = function (n, prec) {
let k = Math.pow(10, prec)
return '' + Math.ceil(n * k) / k
}
s = (prec ? toFixedFix(n, prec) : '' + Math.round(n)).split('.')
let re = /(-?\d+)(\d{3})/
while (re.test(s[0])) {
s[0] = s[0].replace(re, '$1' + sep + '$2')
}
if ((s[1] || '').length < prec) {
s[1] = s[1] || ''
s[1] += new Array(prec - s[1].length + 1).join('0')
}
return s.join(dec)
}
export function byteFormat(bytes) {
if (bytes === 0) return '0 B';
var k = 1024;
let sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
let i = Math.floor(Math.log(bytes) / Math.log(k)) //return (bytes / Math.pow(k, i)) + ' ' + sizes[i];
return (bytes / Math.pow(k, i)).toPrecision(3) + ' ' + sizes[i];
}

View File

@ -0,0 +1,184 @@
import rawMenus from '@/config/menu'
import pluralize from 'pluralize'
import { deepCopy } from './deepcopy';
function capitalize(s) {
return s.replace(/\b(\w)(\w*)/g, ($0, $1, $2) => {
return $1.toUpperCase() + $2.toLowerCase();
})
}
/**
* 获取所有的菜单数据
* @returns
*/
export function getMenus() {
let cloneMenus = deepCopy(rawMenus)
return cloneMenus
}
/**
* 序列化对象
* @param {Object} menu
* @returns
*/
export function nameize(menu) {
let componentName = menu.name || '';
if (componentName === '') {
componentName = menu.route.substr(1).split('/').map(v => {
return v[0] === ":" ? "" : capitalize(pluralize.singular(v));
}).join('')
}
return componentName
}
/**
* 查找指定路由的导航路径
* @param {Array} values
* @param {String} target
*/
export function findNavigation(values, target) {
let paths = [];
for (let i in values) {
let row = values[i];
if (row.route && row.route === target) {
paths.push({
navigation: row.navigation || false,
label: row.label,
route: row.route
})
return paths;
}
if (row.children && Array.isArray(row.children)) {
let vs = findNavigation(row.children, target);
if (Array.isArray(vs)) {
paths.push({
navigation: row.navigation || false,
label: row.label,
route: row.route
});
for (let j in vs) {
paths.push(vs[j])
}
return paths;
}
}
}
return false;
}
/**
* 通过菜单配置构建菜单菜单路由组件
* @param {Array} rows
* @param {Object} components
* @returns
*/
export function buildRoutes(rows, components) {
let routes = [];
for (let menu of rows) {
let route = { name: '', path: menu.route || '/', meta: {}, component: null, props: {} }
route.meta.title = menu.label || '';
route.props.title = route.meta.title
route.name = nameize(menu)
route.meta.hidden = menu.hidden ? true : false
route.meta.cache = menu.cache ? true : false
if (menu.permissions) {
route.meta.permissions = menu.permissions
}
let viewPath = menu.view;
if (!viewPath) {
let tokens = menu.route.substr(1).split('/').map((v) => {
return v[0] === ":" ? "" : v;
}).filter((v) => {
return v !== "";
})
let totalLength = tokens.length;
if (totalLength > 0) {
if (totalLength == 1 || pluralize.isPlural(tokens[totalLength - 1])) {
if (pluralize.isPlural(tokens[totalLength - 1])) {
tokens[totalLength - 1] = pluralize.singular(tokens[totalLength - 1])
}
tokens.push('index');
totalLength++;
}
tokens[totalLength - 1] = capitalize(tokens[totalLength - 1]);
}
viewPath = '../views/' + tokens.join("/") + ".vue"
}
if (components[viewPath]) {
route.component = components[viewPath]
}
if (Array.isArray(menu.children)) {
for (let child of buildRoutes(menu.children, components)) {
if (!child.meta) {
child.meta = {};
}
child.meta.children = true;
child.meta.route = {
parent: menu.route
}
routes.push(child);
}
}
routes.push(route);
}
return routes;
}
/**
* 判断该路由是否有权限访问
* @param {Object} row
* @param {Array} permissions
* @returns
*/
function isAllowed(row, permissions) {
if (row.access && row.access == 'allow') {
return true
}
let name = nameize(row)
if (permissions.indexOf(name) > -1) {
return true
}
//只要有页面下面一个权限,那么就要显示该页面
if (Array.isArray(row.permissions)) {
for (let per of row.permissions) {
if (permissions.indexOf(name + '.' + per)) {
return true
}
}
}
//只要有一个子项目有权限,那么他就有权限
if (Array.isArray(row.children)) {
for (let child of row.children) {
if (isAllowed(child, permissions)) {
return true;
}
}
}
return false
}
function filterMenusInner(items, permissions) {
let data = [];
for (let i in items) {
let row = Object.assign({}, items[i]);
if (!row.hidden) {
if (isAllowed(row, permissions)) {
if (Array.isArray(row.children)) {
row.children = filterMenusInner(row.children, permissions)
}
data.push(row)
}
}
}
return data
}
/**
* 过滤有权限的菜单
* @param {Array} permissions
* @returns
*/
export function filterMenus(permissions) {
return filterMenusInner(getMenus(), permissions)
}

View File

@ -0,0 +1,125 @@
import Color from 'color'
let statusMap = [
{
"label": "空闲",
"value": "idle",
"color": "#198754",
"retain": false
},
{
"label": "忙碌",
"value": "onBreak",
"color": "#F09D00",
"retain": false
},
{
"label": "用餐",
"value": "dining",
"color": "#83261e",
"retain": false
},
{
"label": "休假",
"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
}
]
const statusList = ['answer', 'transfer', 'whisper', 'eavesdrop', 'ringing', 'idle', 'onBreak'];
export function updateStatusMap(x) {
if (Array.isArray(x)) {
statusMap = x;
}
}
export function getStatusPosition(s) {
let pos = statusList.indexOf(s);
if (pos === -1) {
pos = statusList.length + 1
}
return pos
}
export function getUserStatus() {
return statusMap.filter(v => {
return !v.retain;
})
}
export function getStatusText(s) {
for (let i in statusMap) {
let row = statusMap[i];
if (row.value === s) {
return row.label;
}
}
return s;
}
export function getStatusTextColor(s) {
for (let i in statusMap) {
let row = statusMap[i];
if (row.value === s) {
return row.color;
}
}
return '#1c1d21'
}
export function getStatusBGColor(s) {
let c = getStatusTextColor(s);
return Color(c).fade(0.8).hsl()
}

View File

@ -0,0 +1,562 @@
#app {
--el-text-color-primary: #213547;
--el-text-color-muted: #95aac9;
--form-control-width: 210px;
--box-shadow: 0px 0px 30px 8px rgba(0, 0, 0, 0.046);
--placeholder-text-color: #c1ccdb;
--form-control-border-color: #d2ddec;
--el-color-available: #00d97e;
--el-color-link: #0067b8;
.el-card {
margin-bottom: 1.5rem;
}
.el-dialog {
--el-dialog-border-radius: 6px;
.el-dialog__title {
--el-dialog-title-font-size: .95rem;
font-weight: bold;
}
}
.el-footer {
--el-footer-padding: 0 20px;
.d-flex {
height: 100%;
}
}
.cursor-pointer {
cursor: pointer;
}
.el-tag {
border: none !important;
--el-tag-border-radius: 6px;
--el-tag-font-size: .7rem;
&.el-tag--primary {
--el-tag-bg-color: var(--el-color-primary-light-8);
}
&.el-tag--success {
--el-tag-bg-color: var(--el-color-success-light-8);
}
&.el-tag--info {
--el-tag-bg-color: var(--el-color-info-light-8);
}
&.el-tag--warning {
--el-tag-bg-color: var(--el-color-warning-light-8);
}
&.el-tag--danger {
--el-tag-bg-color: var(--el-color-danger-light-8);
}
}
.el-input {
--el-input-border-color: var(--form-control-border-color);
.el-input__inner {
&::placeholder {
font-size: .75rem;
color: var(--placeholder-text-color);
}
}
&.round {
.el-input__wrapper {
border-radius: 20px;
}
}
}
.el-textarea {
--el-input-border-color: var(--form-control-border-color);
.el-textarea__inner {
&::placeholder {
font-size: .75rem;
color: var(--placeholder-text-color);
}
}
}
.el-range-input {
&::placeholder {
font-size: .75rem;
color: var(--placeholder-text-color);
}
}
.el-date-editor {
font-size: .8rem;
--el-input-border-color: var(--form-control-border-color);
}
.el-date-range-picker__time-header {
.el-input {
width: auto !important;
}
}
.el-date-editor--datetimerange {
/**
* TODO ,270
*/
// --el-date-editor-datetimerange-width: calc( - 20px);
--el-date-editor-datetimerange-width: calc(var(--form-control-width) - 16px);
&.el-input__wrapper {
padding: 0 8px;
}
.el-range-input {
font-size: .8rem;
width: 80px;
}
}
.sidebar-drawer {
.el-drawer__body {
--el-drawer-padding-primary: 0;
}
.el-drawer__close {
color: var(--el-color-white);
}
}
}
*::-webkit-scrollbar-thumb {
position: relative;
display: block;
cursor: pointer;
border-radius: inherit;
background-color: rgba(144, 147, 153, 0.3);
border-radius: 10px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(144, 147, 153, 0.5);
}
}
*::-webkit-scrollbar {
z-index: 1;
width: 6px;
height: 6px;
border-radius: 10px;
transition: opacity 0.12s ease-out;
}
.px-1 {
padding-left: .5rem;
padding-right: .5rem;
}
.px-2 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-1 {
padding-top: .5rem;
padding-bottom: .5rem;
}
.py-2 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-muted {
color: var(--el-text-color-muted);
}
.text-primary {
color: var(--el-color-primary);
}
.text-info {
color: var(--el-color-info);
}
.text-success {
color: var(--el-color-success);
}
.text-warning {
color: var(--el-color-warning);
}
.text-error {
color: var(--el-color-error);
}
.text-danger {
color: var(--el-color-error);
}
.flex-align-center {
align-items: center;
}
.d-flex {
display: flex;
&.align-center {
align-items: center;
}
.flex-fill {
flex: 1 1 auto !important;
&.border-right {
border-right: 1px solid var(--el-border-color);
}
&.border-left {
border-left: 1px solid var(--el-border-color);
}
}
.flex-shrink {
flex-shrink: 0;
&.border-right {
border-right: 1px solid var(--el-border-color);
}
&.border-left {
border-left: 1px solid var(--el-border-color);
}
}
&.flex-horizontal,
&.flex-row {
flex-direction: row;
}
&.flex-vertical,
&.flex-column {
flex-direction: column;
}
}
// for segment element
.segment-container {
.segment-header {
align-items: center;
box-sizing: border-box;
padding: 0rem 0rem 1rem .5rem;
h3 {
font-size: 1.038rem;
margin: 0;
color: var(--el-text-color-primary);
// color: var(--el-color-info);
}
}
.segment-body {
padding: 1.5rem 1rem;
border-radius: 10px;
background-color: var(--el-color-white);
box-shadow: var(--box-shadow);
&.plain {
padding: .5rem;
background-color: transparent;
box-shadow: none;
}
}
.segment-toolbar {
text-align: right;
margin-bottom: .38rem;
.iconfont {
margin-left: 1rem;
font-size: .9rem;
cursor: pointer;
color: var(--el-text-color-muted);
transition: all ease .5s;
&:hover {
color: var(--el-text-color-primary);
}
}
ul {
li {
display: inline-block;
}
}
}
&.segment-wrapper {
.segment-header {
margin-bottom: 1rem;
}
}
.el-descriptions {
margin-bottom: 1.5rem;
.el-descriptions__content {
word-break: break-all;
}
}
}
.loading-more {
span {
cursor: pointer;
font-size: .8rem;
color: var(--el-text-color-muted);
}
}
.preview-item,
.segment-dataview,
.segment-descriptions {
.el-tag {
padding-left: 1rem;
padding-right: 1rem;
border: none;
.el-tag__content {
font-size: .76rem;
}
}
.el-pagination {
margin-top: 1rem;
}
.el-table {
th.el-table__cell {
color: var(--el-text-color-primary);
background-color: #f9fbfd;
}
td.el-table__cell {
font-size: .82rem;
}
.cell {
a {
color: var(--el-color-link);
}
}
}
.segment-action {
cursor: pointer;
}
.segment-tag {
display: inline-flex;
justify-content: center;
align-items: center;
height: 24px;
padding: 0 1rem;
line-height: 1;
border-radius: 9999px;
box-sizing: border-box;
white-space: nowrap;
font-size: .76rem;
}
}
.segment-descriptions {
margin-bottom: 1.5rem;
.el-descriptions__label {
display: inline-block;
margin-right: 1rem;
}
.is-bordered {
.el-descriptions__label {
display: table-cell;
margin-right: 0;
}
}
}
.segment-form {
.el-form--inline {
.el-form-item__label {
min-width: 80px;
}
}
.el-textarea {
width: auto !important;
min-width: 80%;
}
.el-upload-dragger {
--el-upload-dragger-padding-horizontal: 1px;
--el-upload-dragger-padding-vertical: 20px;
font-size: .8rem;
}
.grid-form {
.el-textarea {
width: 100% !important;
}
}
.el-input {
width: var(--form-control-width);
&.round {
--el-input-border-radius: 30px;
}
.el-input__wrapper {
transition: all .8s;
padding: 1px 10px;
&:hover {
background-color: var(--el-input-bg-color, var(--el-fill-color-blank));
}
}
.el-textarea__inner {
background-color: rgb(242, 243, 245);
transition: all .8s;
&:hover {
background-color: var(--el-input-bg-color, var(--el-fill-color-blank));
}
}
}
.icon-info {
margin-left: .8rem;
}
}
.segment-action {
display: inline-block;
margin-right: .5rem;
&:last-child {
margin-right: 0;
}
}
.list-plain {
list-style: none;
padding: 0;
margin: 0;
}
a {
text-decoration: none;
}
.avatar-group {
display: inline-flex;
box-sizing: border-box;
padding: 5px 0;
align-items: center;
.avatar-smaples {
display: inline-flex;
justify-content: center;
}
.avatar-plus {
text-align: center;
display: inline-flex;
justify-content: center;
align-items: center;
width: var(--el-avatar-size);
height: var(--el-avatar-size);
border-radius: var(--el-avatar-size);
background-color: var(--el-fill-color);
cursor: pointer;
transition: all .5s;
margin-right: .5rem;
&:hover {
background-color: var(--el-fill-color-darker);
}
}
.el-avatar {
margin-left: calc(-1* var(--el-avatar-size) / 4);
&:first-child {
margin-left: 0;
}
}
}
.avatar-container {
.avatar-list {
margin-bottom: 1rem;
.el-avatar{
margin-right: .25rem;
margin-bottom: .25rem;
cursor: pointer;
}
}
.avatar-footer{
text-align: right;
font-size: .8rem;
}
}
.invisible-form {
.el-button {
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: transparent;
--el-button-hover-border-color: transparent;
}
.el-select {
--el-input-border-color: transparent;
--el-select-input-focus-border-color: transparent;
--el-select-border-color-hover: transparent;
}
.el-input {
--el-input-border-color: transparent !important;
--el-input-bg-color: transparent;
--el-input-icon-color: var(--el-text-color-primary);
}
}
@media only screen and (max-width: 768px) {
#app {
.el-main {
--el-main-padding: 10px 5px;
}
}
}

View File

@ -0,0 +1,28 @@
@forward 'element-plus/theme-chalk/src/common/var.scss' with ($colors: ('primary': ('base': #0b57d0,
),
'success': ('base': #00d97e,
),
'warning':('base':#F09D00,
),
'danger':('base':#e63757,
),
'error':('base':#b3261e,
),
'info':('base':#6e84a3,
)),
);
@use "element-plus/theme-chalk/src/index.scss" as *;
@import 'element-plus/theme-chalk/display.css';
.el-cascader {
.el-input {
.icon-arrow-down {
&::before {
content: none !important;
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,296 @@
<template>
<div class="segment-form" ref="activeformElement">
<el-form :model="activeModel" :label-width="columnLabelWidth" :rules="rules" :validate-on-rule-change="false"
:inline="isInline" ref="activeform" status-icon>
<slot name="container" :model="activeModel" :schemas="visibleColumns">
<el-row :gutter="20" v-if="grid">
<el-col :span="getColSpan(schema)" v-for="(schema, index) in displayColumns" :key="index">
<el-form-item :prop="schema.column" :label="schema.label" :error="getFieldError(schema)">
<slot name="default" :model="activeModel" :schema="schema">
<form-item :model="activeModel" :schema="schema" :scenario="scenario"></form-item>
</slot>
</el-form-item>
</el-col>
</el-row>
<template v-else>
<el-form-item v-for="(schema, index) in displayColumns" :key="index" :prop="schema.column"
:label="schema.label" :error="getFieldError(schema)">
<slot name="default" :model="activeModel" :schema="schema">
<form-item :model="activeModel" :schema="schema" :scenario="scenario"></form-item>
</slot>
</el-form-item>
</template>
<el-form-item v-if="actions.length > 0">
<template v-for="action in actions">
<action :action="action" @click="handleSubmit"></action>
</template>
</el-form-item>
</slot>
</el-form>
</div>
</template>
<style lang="scss"></style>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { encode, decode } from './libs/codec'
import FormItem from './parts/FormItem.vue';
import Action from './parts/Action.vue';
import { useRoute } from 'vue-router';
import { generateSchemaRule, checkSchemaVisible } from './libs/form'
const props = defineProps({
size: {
type: String,
},
schemas: {
type: Array,
},
scenario: {
type: String,
default: 'create'
},
labelWidth: {
type: String,
default: ''
},
model: {
type: Object
},
inline: {
type: Boolean,
default: false
},
grid: {
type: Boolean,
default: false,
},
gridColumn: {
type: Number,
default: 0,
},
actions: {
type: Array
},
autoCommit: {
type: Boolean,
default: false,
}
})
const route = useRoute()
const emit = defineEmits(['submit'])
const activeform = ref(null);
const activeformElement = ref(null);
const activeModel = ref({});
const formWidth = ref(window.innerWidth);
/**
* 显示的列的schema
*/
const visibleColumns = ref([])
/**
* 排除的列
*/
const excludeColumns = ref([])
const displayColumns = computed(() => {
return visibleColumns.value.filter((v) => {
return !v.attribute.invisible;
})
})
const isInline = computed(() => {
if (props.grid) {
return false;
}
return props.inline
})
const columnLabelWidth = computed(() => {
if (props.inline) {
return 0
} else {
if (props.labelWidth) {
return props.labelWidth
} else {
let width = formWidth.value;
if (width < 768) {
return '80px'
} else {
return '120px'
}
}
}
})
const rules = computed(() => {
let rules = {}
if (props.scenario === 'search') {
return rules;
}
for (let schema of visibleColumns.value) {
rules[schema.column] = generateSchemaRule(schema, props.scenario)
}
return rules
})
const actions = computed(() => {
return props.actions || []
})
const getColSpan = (schema) => {
if (props.gridColumn > 0) {
return props.gridColumn
}
if (schema.format == 'textarea') {
return 24;
}
let width = formWidth.value;
if (width < 768) {
return 24;
}
if (width < 960) {
return 12;
}
return 8;
}
onMounted(() => {
visibleColumns.value = props.schemas.filter(schema => {
if (excludeColumns.value.indexOf(schema.column) > -1) {
return false;
}
if (Array.isArray(schema.scenarios)) {
if (schema.scenarios.indexOf(props.scenario) > -1) {
//
if (schema.format === 'cascader') {
let columns = schema.attribute.live.columns;
if (Array.isArray(columns)) {
for (let i in columns) {
if (columns[i] !== schema.column) {
excludeColumns.value.push(columns[i]);
}
}
}
}
return checkSchemaVisible(schema, activeModel.value)
}
}
return false;
})
//
let model = props.model || {};
if (props.scenario === 'search') {
for (let i in visibleColumns.value) {
let schema = visibleColumns.value[i];
if (route.query.hasOwnProperty(schema.column)) {
model[schema.column] = route.query[schema.column];
}
}
}
activeModel.value = decode(model, props.schemas, props.scenario)
if (props.autoCommit) {
nextTick(() => {
submit().then(res => {
for (let i in props.actions) {
let action = props.actions[i];
if (typeof action.callback === 'function') {
action.callback(res, visibleColumns.value);
}
}
}).catch(e => {
})
})
}
formWidth.value = activeformElement.value.offsetWidth;
//
watch(() => props.model, (val) => {
activeModel.value = decode(val, props.schemas, props.scenario)
}, { deep: true })
})
/**
* 监控模型的值动态调整字段显示操作
*/
watch(activeModel, () => {
if (props.scenario !== 'search') {
visibleColumns.value = props.schemas.filter(schema => {
if (excludeColumns.value.indexOf(schema.column) > -1) {
return false;
}
if (Array.isArray(schema.scenarios)) {
if (schema.scenarios.indexOf(props.scenario) > -1) {
return checkSchemaVisible(schema, activeModel.value)
}
}
return false;
})
}
}, { deep: true })
const getFieldError = (schema) => {
//
if (props.scenario === 'search') {
return ''
}
return schema.error || ''
}
const submit = () => {
return new Promise((resolve, reject) => {
let model = encode(activeModel.value, visibleColumns.value, props.scenario);
//
let primaryKeySchemas = props.schemas.filter(v => {
return v.attribute.primary_key
})
if (primaryKeySchemas.length > 0) {
for (let schema of primaryKeySchemas) {
if (props.model && props.model.hasOwnProperty(schema.column)) {
model[schema.column] = props.model[schema.column]
}
}
}
//
activeform.value.validate().then(res => {
emit('submit', model, visibleColumns.value)
resolve(model)
}).catch(e => {
let errors = null;
if (typeof e === 'object') {
for (let i in e) {
errors = e[i];
break;
}
}
if (Array.isArray(errors) && errors.length > 0) {
reject(new Error(errors[0].message))
} else {
reject(new Error('validate error'))
}
})
})
}
const handleSubmit = (action) => {
if (typeof action.callback === 'function') {
submit().then(res => {
action.callback(res, visibleColumns.value);
}).catch(e => { console.log(e.message) })
}
}
defineExpose({ submit })
</script>

View File

@ -0,0 +1,71 @@
<template>
<div class="segment-descriptions">
<el-descriptions :column="column" :border="border">
<template #title>
<slot name="title" :model="model"></slot>
</template>
<template #extra>
<slot name="extra" :model="model"></slot>
</template>
<el-descriptions-item :label="schema.label" v-for="schema in visibleSchemas" :span="getSpan(schema)">
<slot name="default" :model="model" :schema="schema">
<cell :schema="schema" :model="model"></cell>
</slot>
</el-descriptions-item>
</el-descriptions>
<div class="segment-descriptions_footer">
<slot name="footer" :model="model"></slot>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import Cell from './parts/Cell.vue';
import { checkSchemaVisible } from './libs/form';
import { modelValueOf } from './libs/model';
const props = defineProps({
size: {
type: String,
},
schemas: {
type: Array,
},
scenario: {
type: String,
default: 'view',
},
model: {
type: Object
},
column: {
type: Number,
default: 2,
},
border: {
type: Boolean,
default: true
}
})
const getSpan = (schema) => {
if (['textarea'].indexOf(schema.format) > -1) {
return props.column;
} else {
return 1;
}
}
const visibleSchemas = computed(() => {
let modelValue = modelValueOf(props.model);
return props.schemas.filter(v => {
if (Array.isArray(v.scenarios)) {
if (v.scenarios.indexOf(props.scenario) > -1) {
return checkSchemaVisible(v, modelValue);
}
}
return false;
})
})
</script>

View File

@ -0,0 +1,106 @@
<template>
<el-table :data="models" :size="size" :border="true" :loading="loading" @selection-change="handleSelectionChange"
@sort-change="handleSortableChange" @header-dragend="handleHeaderDragend">
<el-table-column v-if="selection" type="selection" width="55"></el-table-column>
<el-table-column v-for="(schema, index) in visibleSchemas" :key="index" :prop="schema.column" :label="schema.label"
:sortable="schema.attribute.sort ? 'custom' : false" :show-overflow-tooltip="true">
<template #default="scope">
<slot :model="scope.row" :schema="schema">
<cell :model="scope.row" :schema="schema"></cell>
</slot>
</template>
</el-table-column>
<el-table-column v-if="actions.length > 0" fixed='right' class-name="segment-gridview-actions">
<template #default="scope">
<template v-for="action in actions">
<action :action="action" v-if="isVisible(action, scope.row)" @click="handleSubmit(action, scope.row)">
</action>
</template>
</template>
</el-table-column>
<template #empty>
<el-empty />
</template>
</el-table>
</template>
<style lang="scss"></style>
<script setup>
import { computed, ref } from 'vue';
import Action from './parts/Action.vue';
import Cell from './parts/Cell.vue';
const props = defineProps({
size: {
type: String
},
schemas: {
type: Array
},
scenario: {
type: String
},
selection: {
type: Boolean,
default: true
},
models: {
type: Array
},
actions: {
type: Array,
}
})
const loading = ref(false)
const visibleSchemas = computed(() => {
return props.schemas.filter(v => {
if (Array.isArray(v.scenarios)) {
if (v.scenarios.indexOf(props.scenario) > -1) {
return !v.attribute.invisible;
}
}
return false;
})
})
const emit = defineEmits(['selection', 'sort', 'dragend'])
const isVisible = (action, model) => {
if (typeof action !== 'object') {
return true;
}
if (!action.hasOwnProperty('hidden')) {
return true;
}
let v = action['hidden'];
if (typeof v === 'boolean') {
return !v
}
if (typeof v === 'function') {
return !v(model)
}
return true;
}
const handleSubmit = (action, model) => {
if (typeof action.callback === 'function') {
action.callback(model, props.schemas)
}
}
const handleSelectionChange = (e) => {
emit('selection', e)
}
const handleSortableChange = (e) => {
emit('sort', e)
}
const handleHeaderDragend = () => {
}
</script>

View File

@ -0,0 +1,12 @@
<template>
<div class="segment-header d-flex">
<div class="flex-fill">
<slot name="headerleft">
</slot>
</div>
<div class="flex-shrink">
<slot name="headerright">
</slot>
</div>
</div>
</template>

View File

@ -0,0 +1,45 @@
<template>
<el-skeleton :rows="8" :throttle="200" :loading="loading" animated>
<template #default>
<slot name="default" :crud="crud"></slot>
</template>
</el-skeleton>
</template>
<script setup>
import { ElMessage } from 'element-plus';
import { onMounted, ref, nextTick } from 'vue';
import CRUD from './libs/crud'
const props = defineProps({
moduleName: {
type: String,
},
tableName: {
type: String,
}
})
const emit = defineEmits(['ready'])
const loading = ref(false)
const crud = ref(new CRUD({
moduleName: props.moduleName,
tableName: props.tableName,
apiPrefix: ''
}))
onMounted(() => {
loading.value = true
crud.value.initialize().then(res => {
nextTick(() => {
loading.value = false
})
emit('ready', crud)
}).catch(e => {
ElMessage.error(e.message)
})
})
</script>

View File

@ -0,0 +1,592 @@
<template>
<skeleton :module-name="moduleName" :table-name="tableName" @ready="handleReady">
<template #default>
<div class="segment-container" v-if="isReady">
<div v-if="!disableHeader" class="segment-header d-flex">
<div class="flex-fill">
<slot name="headerleft">
<h3 v-if="!displayNavigation">{{ title }}</h3>
<el-breadcrumb :separator-icon="ArrowRight" v-else>
<template v-for="item in breadcrumbs">
<el-breadcrumb-item v-if="item.navigation" :to="item.route">
{{ item.label }}
</el-breadcrumb-item>
<el-breadcrumb-item v-else>{{ item.label }}</el-breadcrumb-item>
</template>
</el-breadcrumb>
</slot>
</div>
<div class="flex-shrink">
<slot name="headerright">
<el-button type="primary" round @click="handleCreate"
v-if="hasPermission('create') && !readonly">新建</el-button>
</slot>
</div>
</div>
<div class="segment-body" :class="disableHeader ? 'plain' : ''">
<div class="segment-search">
<active-form :schemas="schemas" :size="size" scenario="search" :model="searchModel" :inline="true"
:actions="searchButtons" :auto-commit="autoQuery">
<template #default="{ model, schema }">
<slot name="searchform" :model="model" :schema="schema"></slot>
</template>
</active-form>
</div>
<div class="segment-toolbar" v-if="!disableToolbar">
<el-dropdown placement="bottom-end">
<icon name="set"></icon>
<template #dropdown>
<el-dropdown-menu>
<template v-for="action in toolbarButtons">
<el-dropdown-item
v-if="(!action.selection || (action.selection && selections.length > 0))"
@click="handleToolbarAction(action)">{{ action.label }}</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="segment-dataview" v-loading="searching">
<grid-view :schemas="schemas" :size="size" scenario="list" :models="models"
:actions="gridviewButtons" @selection="handleSelectionChange" @sort="handleSortChange">
<template #default="{ model, schema }">
<slot name="gridview" :model="model" :schema="schema"></slot>
</template>
</grid-view>
<el-pagination :page-size="pageSize" layout="total, prev, pager, next" :total="totalCount"
@current-change="handlePageChange" />
</div>
</div>
</div>
<el-empty v-else></el-empty>
<template v-if="formMode === 'drawer'">
<el-drawer v-model="formVisible" :title="formTitle" :size="formWidth" :destroy-on-close="true"
:show-close="true">
<template #default>
<active-form :schemas="schemas" :size="size" :scenario="formScenario" :model="formModel"
:actions="formButtons">
<template #default="{ model, schema }">
<slot name="crudform" :model="model" :schema="schema"></slot>
</template>
</active-form>
</template>
</el-drawer>
</template>
<template v-else>
<el-dialog v-model="formVisible" :title="formTitle" :width="formWidth" draggable :destroy-on-close="true">
<active-form :schemas="schemas" :size="size" :scenario="formScenario" :model="formModel"
:actions="formButtons">
<template #default="{ model, schema }">
<slot name="crudform" :model="model" :schema="schema"></slot>
</template>
</active-form>
</el-dialog>
</template>
</template>
</skeleton>
</template>
<script setup>
import Skeleton from './Skeleton.vue';
import ActiveForm from './ActiveForm.vue';
import GridView from './GridView.vue';
import CRUD from './libs/crud'
import Icon from '@/components/widgets/Icon.vue';
import { computed, onMounted, ref } from 'vue';
import { clearSearchModel } from './libs/form'
import { ElMessage, ElMessageBox } from 'element-plus';
import ValidateError from './libs/error'
import { useRoute } from 'vue-router';
import useUserStore from '@/stores/user'
import { findNavigation, getMenus } from '@/assets/js/menu';
import { ArrowRight } from '@element-plus/icons-vue';
/**
* @type {CRUD}
*/
var crud;
/**
* 是否已就绪
*/
const isReady = ref(false)
const route = useRoute()
const breadcrumbs = ref([]);
/**
* 定义属性
*/
const props = defineProps({
size: {
type: String,
},
title: {
type: String
},
moduleName: {
type: String
},
tableName: {
type: String
},
formMode: { // [drawer,dialog,target]
type: String,
default: 'dialog',
},
disableHeader: {
type: Boolean,
default: false,
},
disableToolbar: { //
type: Boolean,
default: false,
},
searchActions: { //
type: Array,
},
formActions: { //
type: Array,
},
gridviewActions: { //
type: Array,
},
toolbarActions: {
type: Array,
},
disablePermission: { //
type: Boolean,
default: false
},
defaultSortable: { //
type: String,
default: ''
},
defaultQuery: { //
type: Object,
default: () => {
return {}
}
},
fixedQueue: { //
type: Object,
default: () => {
return {}
}
},
displayNavigation: {
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false
},
autoQuery: {
type: Boolean, //
default: true
},
beforeCreate: {
type: Function,
default: (model, schemas) => {
return model
}
},
beforeUpdate: {
type: Function,
default: (model, schemas) => {
return model
}
}
})
const formVisible = ref(false)
const formScenario = ref('create')
const searchModel = ref({});
const formModel = ref({});
const selections = ref([]);
const searching = ref(false);
const formWidth = computed(() => {
let width = window.innerWidth
if (width < 768) {
return '96%'
} else if (width <= 1180) {
return '80%'
} else if (width <= 1366) {
return '60%'
} else {
return '40%'
}
})
const formTitle = computed(() => {
let title = props.title;
if (typeof title !== 'string') {
return;
}
let skips = ['列表', '管理'];
let length = title.length;
for (let s of skips) {
if (title.indexOf(s) == length - s.length) {
title = title.substring(0, length - s.length);
}
}
let actionText = formScenario.value === 'create' ? '新建' : '更新'
return actionText + (title || '')
})
const hasPermission = (s) => {
if (props.disablePermission) {
return true
}
let ps = s;
if (ps.indexOf('.') === -1) {
ps = route.name + '.' + s;
}
const { hasPermission } = useUserStore();
return hasPermission(ps);
}
/**
* 获取所有的表结构信息
*/
const schemas = computed(() => {
return isReady.value ? crud.value.getSchemas() : []
})
/**
* 获取所有查询信息
*/
const models = computed(() => {
return isReady.value ? crud.value.getModels() : []
})
/**
* 获取分页大小
*/
const pageSize = computed(() => {
let pagesize = isReady.value ? crud.value.getPaginationSize() : 10
return pagesize
})
/**
* 获取条目总数
*/
const totalCount = computed(() => {
return isReady.value ? crud.value.getPaginationCount() : 1
})
/**
* 默认搜索的动作
*/
const defaultSearchActions = {
'search': {
name: 'search',
label: '查询',
type: 'primary',
callback: (model, schemas) => {
searching.value = true
crud.value.resetPagination().setQueryParams(clearSearchModel(model, schemas)).searchModel().then(res => {
searching.value = false
}).catch(e => {
searching.value = false
})
}
}
}
const defaultToolbarActions = {
'export': {
name: 'export',
label: '导出数据',
type: 'primary',
permission: 'export',
callback: (values) => {
crud.value.exportModels().then(res => {
}).catch(e => {
ElMessage.error(`导出失败: ${e.message}`)
})
}
},
'batchDelete': {
name: 'batchDelete',
label: '删除数据',
type: 'danger',
selection: true,
permission: 'delete',
callback: (values) => {
crud.value.deleteModels(values).then(res => {
if (res.total > res.success) {
ElMessage.success(`删除成功${res.success}条,失败${res.total - res.success}`)
} else {
ElMessage.success(`删除成功`)
}
}).catch(e => {
ElMessage.error(`删除失败: ${e.message}`)
})
}
}
}
/**
* 默认表单的动作
*/
const defaultFormActions = {
'save': {
name: 'save',
label: '保存',
type: 'primary',
callback: (model, schemas) => {
let ps = null;
crud.value.resetError()
if (formScenario.value === 'create') {
if (typeof props.beforeCreate === 'function') {
model = props.beforeCreate(model, schemas);
}
ps = crud.value.createModel(model)
} else {
if (typeof props.beforeUpdate === 'function') {
model = props.beforeUpdate(model, schemas);
}
ps = crud.value.updateModel(model)
}
ps.then(res => {
ElMessage.success("保存成功")
formVisible.value = false
}).catch(e => {
if (e instanceof ValidateError) {
crud.value.setColumnError(e.schema.column, e.schema.message)
} else {
ElMessage.error(`保存失败: ${e.message}`)
}
})
}
}
}
/**
* 默认表格的动作
*/
const defaultGridviewActions = {
'edit': {
name: 'edit',
label: '编辑',
type: 'success',
icon: 'edit',
permission: 'update',
callback: (model, schemas) => {
let qs = {
'scenario': 'update',
'__format': 'raw',
}
qs[crud.value.primaryKey] = crud.value.findModelPrimaryKey(model)
crud.value.getModel(qs).then(res => {
formScenario.value = 'update'
formModel.value = res
formVisible.value = true
}).catch(e => {
ElMessage.error(`获取数据失败: ${e.message}`)
})
}
},
'delete': {
name: 'delete',
label: '删除',
type: 'danger',
icon: 'ashbin',
permission: 'delete',
callback: (model, schemas) => {
ElMessageBox.confirm(
'您确定要删除该条目吗?',
'温馨提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(res => {
crud.value.deleteModel(model).then(res => {
ElMessage.success("删除成功")
}).catch(e => {
ElMessage.error(`删除失败: ${e.message}`)
})
}).catch(e => { })
}
}
}
const mergeButtons = (actions, defaults) => {
let buttons = [];
if (!Array.isArray(actions)) {
for (let i in defaults) {
let action = defaults[i];
if (props.readonly && ['edit', 'delete'].indexOf(action['name']) > -1) {
continue
}
if (action['permission']) {
if (hasPermission(action['permission'])) {
buttons.push(action)
}
} else {
buttons.push(action)
}
}
return buttons
}
for (let i in actions) {
let action = actions[i]
if (typeof action === 'string') {
if (!defaults[action]) {
continue
}
action = defaults[action]
}
if (typeof action !== 'object') {
continue
}
if (props.readonly && ['edit', 'delete'].indexOf(action['name']) > -1) {
continue
}
if (action['permission']) {
if (hasPermission(action['permission'])) {
buttons.push(action)
}
} else {
buttons.push(action)
}
}
return buttons
}
/**
* 查询的按钮渲染
*/
const searchButtons = computed(() => {
return mergeButtons(props.searchActions, defaultSearchActions)
})
/**
* 数据列表的按钮渲染
*/
const gridviewButtons = computed(() => {
return mergeButtons(props.gridviewActions, defaultGridviewActions)
})
/**
* 表单的按钮渲染
*/
const formButtons = computed(() => {
return mergeButtons(props.formActions, defaultFormActions)
})
const toolbarButtons = computed(() => {
return mergeButtons(props.toolbarActions, defaultToolbarActions);
})
/**
* 处理创建事件
* @param {any} e
*/
const handleCreate = (e) => {
formScenario.value = 'create'
formVisible.value = true
formModel.value = {}
}
/**
* 处理初始化完成回调
* @param {CRUD} e
*/
const handleReady = (e) => {
crud = e
isReady.value = true;
if (props.defaultSortable != '') {
if (props.defaultSortable.indexOf('-') == 0) {
crud.value.setSortable(props.defaultSortable.substring(1), 'descending')
} else {
crud.value.setSortable(props.defaultSortable, 'ascending')
}
}
if (typeof props.fixedQueue === 'object') {
for (let k in props.fixedQueue) {
crud.value.addFixedQueueParams(k, props.fixedQueue[k]);
}
}
if (typeof props.defaultQuery === 'object') {
for (let k in props.defaultQuery) {
searchModel.value[k] = props.defaultQuery[k];
}
}
//crud.value.searchModel().then(res => { }).catch(e => { })
}
/**
* 处理分页改变
* @param {*} e
*/
const handlePageChange = (e) => {
searching.value = true
crud.value.setPaginationIndex(e).searchModel().then(res => {
searching.value = false
}).catch(e => {
searching.value = false
})
}
/**
* 处理选中值改变
* @param {*} e
*/
const handleSelectionChange = (e) => {
selections.value = e;
}
/**
* 处理排序改变
* @param {*} e
*/
const handleSortChange = (e) => {
searching.value = true
crud.value.setSortable(e.prop, e.order).searchModel().then(res => {
searching.value = false
}).catch(e => {
searching.value = false
})
}
const handleToolbarAction = (a) => {
if (typeof a.callback === 'function') {
a.callback(selections.value);
}
}
onMounted(() => {
if (props.displayNavigation) {
let navs = findNavigation(getMenus(), route.path);
let pos = -1;
for (let i in navs) {
if (navs[i].route == route.path) {
pos = i;
}
}
if (pos > -1) {
breadcrumbs.value = navs.splice(pos - 1, 2);
} else {
breadcrumbs.value = navs;
}
}
})
</script>

View File

@ -0,0 +1,77 @@
<template>
<div class="segment-container segment-wrapper">
<div class="segment-header d-flex">
<div class="flex-fill">
<slot name="headerleft">
<el-breadcrumb :separator-icon="ArrowRight" v-if="navigation">
<template v-for="item in breadcrumbs">
<el-breadcrumb-item v-if="item.navigation" :to="item.route">
{{ item.label }}
</el-breadcrumb-item>
<el-breadcrumb-item v-else>{{ item.label }}</el-breadcrumb-item>
</template>
</el-breadcrumb>
<h3 v-else>
{{ title }}
</h3>
</slot>
</div>
<div class="flex-shrink">
<slot name="headerright">
</slot>
</div>
</div>
<div class="segment-body" :class="plain ? 'plain' : ''">
<slot name="default">
</slot>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { findNavigation, getMenus } from '@/assets/js/menu';
import { ArrowRight } from '@element-plus/icons-vue';
const props = defineProps({
title: {
type: String,
},
navigation: {
type: Boolean,
default: true,
},
fullNavigation: { //使
type: Boolean,
default: false,
},
plain:{
type: Boolean,
default: false,
}
})
const route = useRoute();
const breadcrumbs = ref([]);
onMounted(() => {
let navs = findNavigation(getMenus(), route.path);
if (props.fullNavigation) {
breadcrumbs.value = navs;
} else {
let pos = -1;
for (let i in navs) {
if (navs[i].route == route.path) {
pos = i;
}
}
if (pos > -1) {
breadcrumbs.value = navs.splice(pos - 1, 2);
} else {
breadcrumbs.value = navs;
}
}
})
</script>

View File

@ -0,0 +1,238 @@
import dayjs from "dayjs"
function mustMarshal(v, s) {
let value = null
switch (s) {
case 'integer':
if (v instanceof Date) {
value = v.getTime() / 1000
} else {
value = parseInt(v)
value = isNaN(value) ? 0 : value
}
break
case 'decimal':
case 'float':
value = parseFloat(v)
value = isNaN(value) ? 0 : value
break
case 'boolean':
value = Boolean(v)
break
case 'string':
if (v instanceof Date) {
value = dayjs(v).format("YYYY-MM-DD HH:mm:ss")
} else if (typeof v === 'object') {
value = JSON.stringify(v)
} else {
value = v + ''
}
break
default:
value = v
}
return value
}
/**
* 把对象转换成时间对象
* @param {Object} v
* @returns
*/
export function toDate(v) {
if (typeof v === 'string') {
return dayjs(v, ['YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD']).toDate();
} else if (typeof v === 'number') {
if (v / 1000000000 < 1000) {
v = v * 1000;
}
return new Date(v);
}
return v;
}
/**
* 模型数据编码为数据库数
* @param {Object} raw
* @param {Array} schemas
* @returns
*/
export function encode(raw, schemas, scenario) {
let data = {}
let excludes = []
if (!raw || !schemas) {
return data
}
scenario = scenario || 'unknown';
for (let schema of schemas) {
if (schema.attribute.live.enable && schema.attribute.live.type == 'cascader') {
let columns = schema.attribute.live.columns
//级联数据如果没有配置字段那么对结果进行JSON编码处理
if (!Array.isArray(columns) || columns.length == 0) {
data[schema.column] = JSON.stringify(raw[schema.column])
} else {
let vals = raw[schema.column]
if (vals && Array.isArray(vals)) {
for (let i in vals) {
if (columns[i]) {
//级联数据所有数据都必须是同一个类型的数据
data[columns[i]] = mustMarshal(vals[i], schema.type)
excludes.push(columns[i])
}
}
}
}
} else if (['multiSelect'].indexOf(schema.format) > -1) {
try {
if (typeof raw[schema.column] === 'object') {
data[schema.column] = mustMarshal(
JSON.stringify(raw[schema.column]),
schema.type
)
}
} catch (e) { }
} else if (['date', 'datetime', 'timestamp'].indexOf(schema.format) > -1) {
let value = raw[schema.column];
if (Array.isArray(value)) {
let ps = []
for (let i in value) {
let row = value[i];
ps.push(mustMarshal(toDate(row), schema.type))
}
data[schema.column] = ps.join('/')
} else {
data[schema.column] = mustMarshal(toDate(value), schema.type)
}
} else {
//先前已经有过赋值的列数据
if (excludes.indexOf(schema.column) > -1) {
continue
}
//如果是bool类型的变量并且值为空不进行类型转换
if (['boolean', 'bool'].indexOf(schema.format) > -1) {
if (typeof raw[schema.column] === 'undefined') {
continue
}
if (typeof raw[schema.column] === 'string' && raw[schema.column] == '') {
continue
}
}
if (raw[schema.column]) {
data[schema.column] = mustMarshal(raw[schema.column], schema.type)
} else {
data[schema.column] = mustMarshal(
scenario === 'create' ? schema.attribute.default_value : '',
schema.type
)
}
}
}
return data
}
/**
* 数据库数据解码为模型数据
* @param {Object} raw
* @param {Array} schemas
* @returns
*/
export function decode(raw, schemas, scenario) {
let data = {}
scenario = scenario || 'unknown';
if (!raw || !schemas) {
return data
}
for (let schema of schemas) {
if (
schema.attribute.live.enable &&
schema.attribute.live.type == 'cascader'
) {
let columns = schema.attribute.live.columns
//级联数据如果没有配置字段那么对结果进行JSON解码处理
if (!Array.isArray(columns) || columns.length == 0) {
if (raw[schema.column]) {
data[schema.column] = JSON.parse(raw[schema.column])
} else {
data[schema.column] = []
}
} else {
data[schema.column] = []
for (let column of columns) {
let v = raw[column]
if (!v) {
break
}
data[schema.column].push(mustMarshal(v, schema.type))
}
}
} else if (
(schema.attribute.live.enable &&
schema.attribute.live.type == 'dropdown') ||
schema.format == 'dropdown'
) {
//强制重新设置字段的format
if (schema.format !== 'dropdown') {
schema.format = 'dropdown';
}
//下拉列转成字符串显示
let v = null;
if (raw.hasOwnProperty(schema.column)) {
v = raw[schema.column];
} else if (scenario === 'create') {
v = schema.attribute.default_value;
}
data[schema.column] = mustMarshal(v || '', 'string')
} else if (schema.format === 'multiSelect') {
try {
if (typeof raw[schema.column] === 'string') {
data[schema.column] = JSON.parse(raw[schema.column])
}
} catch (e) { }
} else if (['time', 'date', 'datetime', 'timestamp'].indexOf(schema.format) > -1) {
if (raw.hasOwnProperty(schema.column)) {
let v = raw[schema.column];
if (v) {
if (typeof v === 'string' && v.indexOf('/') > -1) {
let ss = v.split('/', 2);
let st = toDate(ss[0]);
let et = toDate(ss[1]);
switch (schema.format) {
case 'time':
data[schema.column] = [dayjs(st).format('HH:mm'), dayjs(et).format('HH:mm')]
break;
case 'date':
data[schema.column] = [dayjs(st).format('YYYY-MM-DD'), dayjs(et).format('YYYY-MM-DD')];
break;
default:
data[schema.column] = [dayjs(st).format('YYYY-MM-DD HH:mm:ss'), dayjs(et).format('YYYY-MM-DD HH:mm:ss')];
break
}
} else {
let ts = toDate(v);
switch (schema.format) {
case 'time':
data[schema.column] = dayjs(ts).format('HH:mm');
break;
case 'date':
data[schema.column] = dayjs(ts).format('YYYY-MM-DD');
break;
default:
data[schema.column] = dayjs(ts).format('YYYY-MM-DD HH:mm:ss');
break
}
}
}
}
} else {
let v = null;
if (raw.hasOwnProperty(schema.column)) {
v = raw[schema.column];
} else if (scenario === 'create') {
v = schema.attribute.default_value;
}
data[schema.column] = mustMarshal(v || '', schema.type)
}
}
return data
}

View File

@ -0,0 +1,680 @@
import httpclicent from '@/apis/request'
import pluralize from 'pluralize';
import { getModelValue } from './model';
class CRUD {
constructor(opts) {
opts = Object.assign({
moduleName: '',
tableName: '',
apiPrefix: '/v1',
schemas: [],
}, opts);
this.opts = opts
this.primaryKey = ''
this.modelName = opts.moduleName
this.tableName = opts.tableName
this.models = []
this.sortable = []
this.queryParams = {}
this.fixedQueueParams = {}
this.pagination = {
index: 1,
size: 15,
totalCount: 0,
}
//初始化schema
if (Array.isArray(opts.schemas)) {
this.schemas = opts.schemas
} else if (!Array.isArray(opts.schemas) && typeof opts.Array === 'object') {
this.schemas = [];
for (let k in opts.schemas) {
this.schemas.push(opts.schemas[k])
}
} else {
this.schemas = []
}
}
/**
* 预处理一些变量
*/
__prepare() {
for (let i in this.schemas) {
let schema = this.schemas[i]
this.schemas[i].error = ''
if (schema.attribute.primary_key) {
this.primaryKey = schema.column
}
if (schema.attribute.live.enable) {
this._lazyFetach(schema).then(res => {
schema.attribute.values = res
}).catch(e => {
console.log(e.message)
})
}
}
}
_lazyFetach(schema) {
return new Promise((resolve, reject) => {
if (schema.attribute.live.method === 'post') {
httpclicent.post(
schema.attribute.live.url,
schema.attribute.live.body,
{
headers: { 'Content-Type': schema.attribute.live.content_type }
}
).then(res => {
resolve(res)
}).catch(e => {
reject(e)
})
} else {
httpclicent.get(schema.attribute.live.url).then(res => {
resolve(res)
}).catch(e => {
reject(e)
})
}
})
}
/**
* 刷新列表的一个模型数据
* @param {*} primaryKey
*/
__refreshModel(primaryKey) {
return new Promise((resolve, reject) => {
let qs = {}
qs[this.primaryKey] = primaryKey
qs['__format'] = 'both'
qs['scenario'] = 'list'
this.getModel(qs).then(res => {
let isExists = false;
let primaryKey = this.findModelPrimaryKey(res);
if (primaryKey === false) {
reject('can not find model primary key')
return
}
for (let k in this.models) {
let pk = getModelValue(this.models[k], this.primaryKey);
if (pk == primaryKey) {
isExists = true
this.models[k] = res
break
}
}
if (!isExists) {
this.models.push(res)
}
resolve(res)
}).catch(e => { reject(e) })
})
}
/**
* 删除一个模型
* @param {*} primaryKey
*/
__removeModel(primaryKey) {
for (let k in this.models) {
let pk = getModelValue(this.models[k], this.primaryKey);
if (primaryKey === pk) {
this.models.splice(k, 1)
}
}
}
/**
* 构建请求的接口地址
* @param {String} scenario
* @param {any} primaryKey
* @returns
*/
__buildUri(scenario, primaryKey) {
return this.__buildModelUri(this.modelName, this.tableName, scenario, primaryKey);
}
__buildModelUri(moduleName, tableNanme, scenario, primaryKey) {
primaryKey = primaryKey || ''
let uri = this.opts.apiPrefix ? this.opts.apiPrefix : '';
let pluralizeName = pluralize.plural(tableNanme);
let singularName = pluralize.singular(tableNanme);
switch (scenario) {
case 'create':
uri += `/${moduleName}/${singularName}`
break
case 'update':
uri += `/${moduleName}/${singularName}/${primaryKey}`
break
case 'delete':
uri += `/${moduleName}/${singularName}/${primaryKey}`
break
case 'get':
uri += `/${moduleName}/${singularName}/${primaryKey}`
break
case 'search':
uri += `/${moduleName}/${pluralizeName}`
break
case 'export':
uri += `/${moduleName}/${singularName}-export`
break
case 'import':
uri += `/${moduleName}/${singularName}-import`
break
}
return uri
}
/**
* 初始化CRUD组件
* @returns
*/
initialize() {
return new Promise((resolve, reject) => {
if (Array.isArray(this.schemas) && this.schemas.length > 0) {
this.__prepare()
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)
})
})
}
/**
* 获取所有的字段描述信息
* @returns
*/
getSchemas() {
return this.schemas
}
/**
* 设置指定列的错误信息
* @param {string} column
* @param {string} error
*/
setColumnError(column, error) {
for (let i in this.schemas) {
if (this.schemas[i].column === column) {
this.schemas[i].error = error
}
}
}
/**
* 重置所有列的错误信息
*/
resetError() {
for (let i in this.schemas) {
this.schemas[i].error = ''
}
}
/**
* 获取所有模型集合
* @returns
*/
getModels() {
return this.models;
}
/**
* 设置分页索引
* @param {Number} index 分页索引
* @returns CRUD
*/
setPaginationIndex(index) {
this.pagination.index = index
return this
}
/**
* 获取当前分页的索引
* @returns
*/
getPaginationIndex() {
return this.pagination.index
}
/**
* 设置分页大小
* @returns CRUD
* @param {Number} pageSize 分页大小
*/
setPaginationSize(pageSize) {
this.pagination.size = pageSize
return this
}
/**
* 获取分页大小
* @returns
*/
getPaginationSize() {
return this.pagination.size || 10
}
/**
* 获取分页信息中的总数据条数
* @returns
*/
getPaginationCount() {
return this.pagination.totalCount
}
/**
* 重置分页配置
* @returns CRUD
*/
resetPagination() {
this.pagination.index = 1
return this
}
/**
* 设置字段排序
* @param {String} column 字段列
* @param {String} order 排序规则
* @returns CRUD
*/
setSortable(column, order) {
if (!column) {
this.sortable = {}
} else {
this.sortable = {
column: column,
order: order || 'ascending',
}
}
return this;
}
/**
* 添加固定的查询参数
* @param {*} k
* @param {*} v
*/
addFixedQueueParams(k, v) {
this.fixedQueueParams[k] = v;
}
/**
* 设置查询参数
* @param {Object} qs 查询参数
* @returns
*/
setQueryParams(qs) {
this.queryParams = qs
return this
}
/**
* 添加查询参数
* @param {String} k
* @param {String} v
*/
addQueryParams(k, v) {
this.queryParams[k] = v;
}
/**
* 查找模型的主键的值
* @param {Object} model 表模型
* @returns Boolean | mixed
*/
findModelPrimaryKey(model) {
if (typeof model === 'object') {
return getModelValue(model, this.primaryKey);
} else {
return model;
}
}
/**
* 创建一个模型
* @param {Object} model
*/
createModel(model) {
return new Promise((resolve, reject) => {
httpclicent.post(this.__buildUri('create'), model).then(res => {
this.__refreshModel(res.id).then(res => {
resolve(res)
}).catch(e => { reject(e) })
}).catch(e => { reject(e) })
})
}
/**
* 创建一个资源
* @param {String} moduleName
* @param {String} tableName
* @param {Object} model
* @returns
*/
createResource(moduleName, tableName, model) {
return new Promise((resolve, reject) => {
httpclicent.post(this.__buildModelUri(moduleName, tableName, 'create'), model).then(res => {
resolve(res)
}).catch(e => { reject(e) })
})
}
/**
* 更新一个模型
* @param {Object} model
*/
updateModel(model) {
return new Promise((resolve, reject) => {
let primaryKey = this.findModelPrimaryKey(model);
if (primaryKey === false) {
reject('can not find model primary key')
return
}
httpclicent.put(this.__buildUri('update', primaryKey), model).then(res => {
this.__refreshModel(primaryKey).then(res => {
resolve(res)
}).catch(e => { reject(e) })
}).catch(e => { reject(e) })
})
}
/**
* 更新一个资源
* @param {String} moduleName
* @param {String} tableName
* @param {Object} model
*/
updateResource(moduleName, tableName, model) {
return new Promise((resolve, reject) => {
let primaryKey = this.findModelPrimaryKey(model);
if (primaryKey === false) {
reject('can not find model primary key')
return
}
httpclicent.put(this.__buildModelUri(moduleName, tableName, 'update', primaryKey), model).then(res => {
resolve(res)
}).catch(e => { reject(e) })
})
}
/**
* 删除一个模型
* @param {Object} model
*/
deleteModel(model) {
let primaryKey = '';
if (typeof model === 'object') {
primaryKey = this.findModelPrimaryKey(model)
} else {
primaryKey = model
}
return new Promise((resolve, reject) => {
httpclicent.delete(this.__buildUri('delete', primaryKey), model).then(res => {
this.__removeModel(primaryKey)
resolve(res)
}).catch(e => { reject(e) })
})
}
/**
* 删除一个资源
* @param {String} moduleName
* @param {String} tableName
* @param {Object} model
* @returns
*/
deleteResource(moduleName, tableName, model) {
let primaryKey = '';
if (typeof model === 'object') {
primaryKey = this.findModelPrimaryKey(model)
} else {
primaryKey = model
}
return new Promise((resolve, reject) => {
httpclicent.delete(this.__buildModelUri(moduleName, tableName, 'delete', primaryKey), model).then(res => {
resolve(res)
}).catch(e => { reject(e) })
})
}
/**
* 获取一个资源
* @param {String} moduleName
* @param {String} tableName
* @param {Object} model
* @returns
*/
getResource(moduleName, tableName, qs) {
return new Promise((resolve, reject) => {
let primaryKey = '';
let params = {}
if (typeof qs === 'object') {
for (let k in qs) {
if (k === this.primaryKey) {
primaryKey = qs[k]
} else {
params[k] = qs[k]
}
}
} else {
primaryKey = qs
}
if (!primaryKey) {
reject('can not find model primary key')
return
}
httpclicent.get(this.__buildModelUri(moduleName, tableName, 'get', primaryKey), { params: params }).then(res => {
resolve(res)
}).catch(e => { reject(e) })
})
}
/**
*
* @param {*} pk
* @param {*} qs
* @returns
*/
fetchModel(pk, qs) {
if (typeof qs !== 'object') {
qs = {}
}
qs[this.primaryKey] = pk
return this.getModel(qs)
}
/**
* 获取一个模型数据
* @param {*} qs
*/
getModel(qs) {
return new Promise((resolve, reject) => {
let primaryKey = '';
let params = {}
if (typeof qs === 'object') {
for (let k in qs) {
if (k === this.primaryKey) {
primaryKey = qs[k]
} else {
params[k] = qs[k]
}
}
} else {
primaryKey = qs
}
if (!primaryKey) {
reject('can not find model primary key')
return
}
httpclicent.get(this.__buildUri('get', primaryKey), { params: params }).then(res => {
resolve(res)
}).catch(e => { reject(e) })
})
}
/**
* 查询模型数据
*/
searchModel() {
let queryParams = Object.assign({}, this.queryParams);
queryParams['page'] = this.pagination.index
queryParams['pagesize'] = this.pagination.size || 15
if (this.sortable && this.sortable.column != '') {
queryParams['sort'] = this.sortable.order == 'descending' ? '-' + this.sortable.column : this.sortable.column
}
queryParams['__format'] = 'both';
if (typeof this.fixedQueueParams === 'object') {
for (let k in this.fixedQueueParams) {
queryParams[k] = this.fixedQueueParams[k];
}
}
return new Promise((resolve, reject) => {
httpclicent.get(this.__buildUri('search'), { params: queryParams }).then(res => {
this.pagination.index = parseInt(res.page);
this.pagination.size = parseInt(res.pagesize);
this.pagination.totalCount = parseInt(res.totalCount);
this.models = res.data
resolve()
}).catch(e => { reject(e) })
})
}
/**
* 批量删除模型
* @param {Array} data
*/
deleteModels(data) {
let pks = [];
for (let val of data) {
if (typeof val === 'object') {
let pk = this.findModelPrimaryKey(val)
if (pk) {
pks.push(pk)
}
} else {
pks.push(val)
}
}
if (pks.length <= 0) {
return
}
let ps = [];
for (let pk of pks) {
ps.push(this.deleteModel(pk))
}
return new Promise((resolve, reject) => {
Promise.all(ps).then(res => {
let data = {
total: 0,
success: 0,
responses: res,
};
data.total = pks.length
data.success = res.length
resolve(data)
}).catch(e => {
reject(e)
})
})
}
/**
* 下载指定资源的导入模板
* @param {String} moduleName
* @param {String} tableName
* @returns
*/
downloadResourceTemplate(moduleName, tableName) {
return new Promise((resolve, reject) => {
httpclicent.get(this.__buildModelUri(moduleName, tableName, 'import')).then(res => {
let element = document.createElement('a')
let disposition = res.headers['content-disposition']
let filename = this.tableName + '.csv';
//获取文件名称
if (disposition) {
let pos = -1;
let ps = disposition.split(';')
for (let s of ps) {
pos = s.indexOf('filename=')
if (pos === 0) {
filename = s.substring(9)
break
}
}
}
element.style.display = 'none'
element.href = window.URL.createObjectURL(new Blob([res.data], { type: res.headers['content-type'] }))
element.target = '_blank'
element.setAttribute('download', filename)
document.body.appendChild(element)
element.click()
window.URL.revokeObjectURL(element.href)
document.body.removeChild(element)
}).catch(e => { reject(e) })
})
}
/**
* 下载导入模板
* @returns
*/
downloadTemplate() {
return this.downloadResourceTemplate(this.modelName, this.tableName);
}
/**
* 导出模型数据
* @returns
*/
exportModels() {
let queryParams = Object.assign({}, this.queryParams);
if (this.sortable && this.sortable.column != '') {
queryParams['sort'] = this.sortable.order == 'descending' ? '-' + this.sortable.column : this.sortable.column
}
return new Promise((resolve, reject) => {
httpclicent.get(this.__buildUri('export'), { params: queryParams }).then(res => {
let element = document.createElement('a')
let disposition = res.headers['content-disposition']
let filename = this.tableName + '.csv';
//获取文件名称
if (disposition) {
let pos = -1;
let ps = disposition.split(';')
for (let s of ps) {
pos = s.indexOf('filename=')
if (pos === 0) {
filename = s.substring(9)
break
}
}
}
element.style.display = 'none'
element.href = window.URL.createObjectURL(new Blob([res.data], { type: res.headers['content-type'] }))
element.target = '_blank'
element.setAttribute('download', filename)
document.body.appendChild(element)
element.click()
window.URL.revokeObjectURL(element.href)
document.body.removeChild(element)
}).catch(e => { reject(e) })
})
}
}
export default CRUD

View File

@ -0,0 +1,9 @@
class ValidateError extends Error {
constructor(column) {
super(column.message)
this.schema = column
this.name = 'ValidateError'
}
}
export default ValidateError

View File

@ -0,0 +1,161 @@
/**
* 清理搜索模型的空字段
* @param {Object} model
* @param {Array} schemas
*/
export function clearSearchModel(model, schemas) {
let ps = {}
for (let schema of schemas) {
if (schema.type === 'string') {
if (model[schema.column] === '') {
continue
}
}
if (['integer', 'double'].indexOf(schema.type) > -1 && ['boolean', 'bool'].indexOf(schema.format) === -1) {
if (model[schema.column] === 0) {
continue
}
}
ps[schema.column] = model[schema.column]
}
return ps;
}
/**
* 判断指定的字段是否显示
* @param {Object} schema
* @param {Object} model
*/
export function checkSchemaVisible(schema, model) {
if (!Array.isArray(schema.attribute.visible)) {
return true
}
if (schema.attribute.visible.length <= 0) {
return true
}
for (let cond of schema.attribute.visible) {
let column = cond['column']
let values = cond['values']
if (!column || column === '') {
continue
}
values.map((v) => {
return v + ''
})
let compareValue = model[column]
if (typeof compareValue === 'undefined') {
return false
}
if (values.indexOf(compareValue + '') === -1) {
return false
}
}
return true
}
/**
* 生成规则
* @param {Object} schema
* @param {String} scenario
* @returns
*/
export function generateSchemaRule(schema, scenario) {
let rules = []
if (schema.rule.required.indexOf(scenario) > -1) {
rules.push({
required: true,
message: `${schema.label}字段的值不能为空`,
trigger: 'blur',
})
}
//长度过滤把在线搜索的情况过滤掉
if (['string', 'text'].indexOf(schema.type) > -1 && schema.format !== 'cascader') {
rules.push({
type: 'string',
message: `${schema.label}字段的值不是有效的字符串`,
trigger: 'blur',
})
if (schema.rule.max > 0 && schema.rule.min > 0) {
rules.push({
max: schema.rule.max,
min: schema.rule.min,
message: `${schema.label}字段长度必须在${schema.rule.min} - ${schema.rule.max}个字符之间`,
trigger: 'blur',
})
} else if (schema.rule.max > 0) {
rules.push({
max: schema.rule.max,
message: `${schema.label}字段长度不能大于${schema.rule.max}个字符`,
trigger: 'blur',
})
}
}
if (['integer', 'decimal', 'number'].indexOf(schema.type) > -1) {
if (['date', 'datetime', 'timestamp'].indexOf(schema.format) == -1) {
rules.push({
type: 'number',
message: `${schema.label}字段的值不是有效的数字`,
trigger: 'blur',
})
if (schema.rule.min !== 0 && schema.rule.max !== 0) {
rules.push({
max: schema.rule.max,
min: schema.rule.min,
message: `${schema.label}的值必须在${schema.rule.min} - ${schema.rule.max}之间`,
trigger: 'blur',
})
} else if (schema.rule.max !== 0) {
// rules.push({
// max: schema.rule.max,
// message: `${schema.label}最大值不能大于${schema.rule.max}`,
// trigger: 'blur',
// })
}
}
}
if (['date', 'datetime', 'timestamp'].indexOf(schema.format) > -1) {
rules.push({
type: 'date',
message: `${schema.label}字段的值是无效的`,
trigger: 'blur',
})
}
if (schema.rule.regular !== '') {
rules.push({
type: 'regexp',
pattern: schema.rule.regular,
message: `${schema.label}字段的值不符合规范`,
trigger: 'blur',
})
}
return rules
}
export function generateSchemaDescription(schema, scenario) {
if (typeof schema.attribute.description === 'string' && schema.attribute.description !== '') {
return schema.attribute.description
}
let messages = []
if (schema.rule.required.indexOf(scenario) > -1) {
messages.push("不能为空")
}
if (['string', 'text'].indexOf(schema.type) > -1) {
if (schema.rule.max > 0 && schema.rule.min > 0) {
messages.push(`长度必须在${schema.rule.min} - ${schema.rule.max}个字符之间`)
} else if (schema.rule.max > 0) {
messages.push(`长度必须小于${schema.rule.max}个字符`)
}
}
if (['integer', 'decimal', 'number'].indexOf(schema.type) > -1) {
if (schema.rule.max > 0 && schema.rule.min > 0) {
messages.push(`值必须在${schema.rule.min} - ${schema.rule.max}之间`)
} else if (schema.rule.max > 0) {
messages.push(`值必须小于${schema.rule.max}`)
}
}
return messages.join(', ')
}

View File

@ -0,0 +1,60 @@
export function getModelValue(model, column) {
if (typeof model !== 'object') {
return null;
}
if (!model.hasOwnProperty(column)) {
return null;
}
let value = model[column];
if (typeof value === 'object' && value.hasOwnProperty('value')) {
return value['value']
}
return value;
}
export function getModelLabel(model, column) {
if (typeof model !== 'object') {
return null;
}
if (!model.hasOwnProperty(column)) {
return null;
}
let value = model[column];
if (typeof value === 'object' && value.hasOwnProperty('label')) {
return value['label']
}
return value;
}
export function modelValueOf(model) {
if (typeof model !== 'object') {
return {};
}
let result = {}
for (let i in model) {
let row = model[i];
if (typeof row === 'object' && row.hasOwnProperty('value')) {
result[i] = row['value']
} else {
result[i] = row
}
}
return result;
}
export function modelLabelOf(model) {
if (typeof model !== 'object') {
return {};
}
let result = {}
for (let i in model) {
let row = model[i];
if (typeof row === 'object' && row.hasOwnProperty('label')) {
result[i] = row['label']
} else {
result[i] = row
}
}
return result;
}

View File

@ -0,0 +1,46 @@
<template>
<template v-if="action.icon">
<template v-if="action.label">
<el-tooltip effect="dark" :content="action.label" placement="top-start">
<icon @click="handleSubmit" :name="action.icon" class="segment-action cursor-pointer"
:class="`text-color-${action.type || 'default'}`">
</icon>
</el-tooltip>
</template>
<template v-else>
<icon @click="handleSubmit" :name="action.icon" class="segment-action cursor-pointer"
:class="`text-color-${action.type || 'default'}`"></icon>
</template>
</template>
<template v-else>
<el-button :type="action.type || 'default'" :round="action.round || false" :size="action.size" class="segment-action" @click="handleSubmit">
{{ action.label }}
</el-button>
</template>
</template>
<style lang="scss">
.segment-action {
margin-right: .5rem;
&:last-child{
margin-right: 0;
}
}
</style>
<script setup>
import Icon from '@/components/widgets/Icon.vue';
const props = defineProps({
action: {
type: Object
}
})
const emit = defineEmits(['click'])
const handleSubmit = () => {
emit('click', props.action)
}
</script>

View File

@ -0,0 +1,195 @@
<template>
<template v-if="tagVisible">
<span class="segment-gridview-cell segment-tag" :style="{ color: textColor, backgroundColor: backgroundColor }">
{{ value }}
</span>
</template>
<template v-else-if="['boolean', 'bool'].indexOf(schema.format) > -1">
<el-tag round :type="isTrue ? 'success' : 'danger'"> {{ formatBoolean(isTrue) }}</el-tag>
</template>
<template v-else>
<span class="segment-gridview-cell">{{ value }}</span>
</template>
</template>
<script setup>
import { computed } from 'vue';
import Color from 'color'
import { getModelValue } from '../libs/model';
const props = defineProps({
model: {
type: Object,
},
schema: {
type: Object
}
})
const recursiveFind = (hack, values) => {
if (!Array.isArray(values)) {
return hack;
}
for (let i in values) {
let row = values[i];
if (row.value === hack) {
return row.label;
}
if (row.children && Array.isArray(row.children)) {
let v = recursiveFind(hack, row.children)
if (v) {
return v
}
}
}
return '';
}
const valueFind = (hack, values) => {
let result = '';
let item = '';
if (!Array.isArray(values)) {
return hack;
}
if (Array.isArray(hack)) {
item = hack.shift()
} else {
item = hack;
}
for (let i in values) {
let row = values[i];
if (row.value === item) {
result = row.label;
if (row.children && Array.isArray(row.children)) {
let childVal = valueFind(hack, row.children);
if (childVal) {
result += "/" + childVal;
}
}
break;
}
}
return result;
}
/**
* 返回渲染的值
*/
const value = computed(() => {
let model = '';
let text = '';
let value = '';
let str = '';
if (props.model[props.schema.column]) {
model = props.model[props.schema.column]
}
if (typeof model === 'object') {
text = model.label;
value = model.value;
} else {
text = model
value = model
}
if (props.schema.format === 'cascader' && Array.isArray(props.schema.attribute.values) && props.schema.attribute.values.length > 0) {
if (typeof value === 'string') {
try {
value = JSON.parse(value);
str = valueFind(value, props.schema.attribute.values)
} catch (e) {
}
} else if (Array.isArray(value)) {
str = valueFind(value, props.schema.attribute.values)
} else {
str = value;
}
} else {
str = recursiveFind(value, props.schema.attribute.values);
}
if (str) {
text = str;
}
return text;
})
const isTrue = computed(() => {
let value = getModelValue(props.model, props.schema.column);
if (typeof value === 'undefined') {
return false
}
return value ? true : false
})
const formatBoolean = (v) => {
if (v) {
return '是'
} else {
return '否'
}
}
const tagVisible = computed(() => {
if (['dropdown'].indexOf(props.schema.format) > -1) {
let hasColor = true
//
for (let i in props.schema.attribute.values) {
if (!props.schema.attribute.values[i].color) {
hasColor = false
}
}
return hasColor
}
return false
})
const textColor = computed(() => {
if (['dropdown'].indexOf(props.schema.format) > -1) {
let data = props.model[props.schema.column];
let values = props.schema.attribute.values;
for (let row of values) {
if (typeof data === 'object') {
if (row.value == data.value) {
if (row.color) {
return row.color
}
}
} else {
if (row.label == data || row.value == data) {
if (row.color) {
return row.color
}
}
}
}
}
return ''
})
const backgroundColor = computed(() => {
if (['dropdown'].indexOf(props.schema.format) > -1) {
let data = props.model[props.schema.column];
let values = props.schema.attribute.values;
for (let row of values) {
if (typeof data === 'object') {
if (row.value == data.value) {
if (row.color) {
return Color(row.color).fade(0.9).hsl()
}
}
} else {
if (row.label == data || row.value == data) {
if (row.color) {
return Color(row.color).fade(0.9).hsl()
}
}
}
}
}
return ''
})
</script>

View File

@ -0,0 +1,281 @@
<template>
<template v-if="isVisible('time', schema)">
<el-time-select v-model="model[schema.column]" start="00:00" step="00:15" end="23:59"
:disabled="isDisabled(schema)" :placeholder="getPlaceholder(schema)" format="HH:mm" />
</template>
<template v-else-if="isVisible('date', schema)">
<el-date-picker v-if="isRange(schema)" :disabled="isDisabled(schema)" type="daterange" :editable="false"
format="YYYY-MM-DD" value-format="YYYY-MM-DD" v-model="model[schema.column]"
:start-placeholder="getStartPlaceholder(schema)"
:end-placeholder="getEndPlaceholder(schema)"></el-date-picker>
<el-date-picker v-else :disabled="isDisabled(schema)" type="date" v-model="model[schema.column]"
:editable="false" format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:placeholder="getPlaceholder(schema)"></el-date-picker>
</template>
<template v-else-if="isVisible('datetime', schema)">
<el-date-picker v-if="isRange(schema)" :disabled="isDisabled(schema)" :editable="false" format="YYYY-MM-DD"
value-format="YYYY-MM-DD HH:mm:ss" time-format="HH:mm:ss" type="datetimerange" :prefix-icon="Calendar"
v-model="model[schema.column]" :teleported="false" :start-placeholder="getStartPlaceholder(schema)"
:disabled-date="getIsDateDisable" :end-placeholder="getEndPlaceholder(schema)"></el-date-picker>
<el-date-picker v-else :disabled="isDisabled(schema)" type="datetime" :editable="false"
format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" v-model="model[schema.column]"
:placeholder="getPlaceholder(schema)"></el-date-picker>
</template>
<template v-else-if="isVisible('dropdown', schema)">
<el-select clearable v-if="!isDropdownMulitLevel(schema)" v-model="model[schema.column]"
:multiple="schema.format === 'multiSelect'" :disabled="isDisabled(schema)"
:placeholder="getPlaceholder(schema)" :allow-create="isDropdownCreated(schema)"
:filterable="isDropdownFilterable(schema)" :default-first-option="isDropdownDefaultFirst(schema)">
<template v-for="(v, k) in schema.attribute.values" :key="k">
<el-option v-if="typeof v === 'object'" :label="v.label" :value="v.value"></el-option>
<el-option v-else :label="v" :value="k"></el-option>
</template>
</el-select>
<level-select v-model="model[schema.column]" :options="schema.attribute.values" clearable v-else></level-select>
</template>
<template v-else-if="isVisible('search_boolean', schema)">
<el-select clearable v-model="model[schema.column]">
<template v-for="(v, k) in booleanValues" :key="k">
<el-option :label="v" :value="k"></el-option>
</template>
</el-select>
</template>
<template v-else-if="isVisible('cascader', schema)">
<el-cascader :disabled="isDisabled(schema)" :options="schema.attribute.values" filterable clearable
:placeholder="getPlaceholder(schema)" v-model="model[schema.column]" :validate-event="false"></el-cascader>
</template>
<template v-else-if="isVisible('color', schema)">
<el-color-picker v-model="model[schema.column]" :disabled="isDisabled(schema)" />
</template>
<template v-else-if="isVisible('boolean', schema)">
<el-switch :disabled="isDisabled(schema)" :inactive-value="0" :active-value="1"
v-model="model[schema.column]"></el-switch>
</template>
<template v-else-if="isVisible('file', schema)">
<upload :action="schema.attribute.upload_url" v-model="model[schema.column]"></upload>
</template>
<template v-else-if="isVisible('password', schema)">
<el-input :disabled="isDisabled(schema)" show-password :placeholder="getPlaceholder(schema)"
v-model.trim="model[schema.column]"></el-input>
</template>
<template v-else-if="isVisible('multistr', schema)">
<el-input type="textarea" :disabled="isDisabled(schema)" :placeholder="getPlaceholder(schema)"
:show-word-limit="isShowWordLimit(schema)" :maxlength="schema.rule.max"
v-model="model[schema.column]"></el-input>
</template>
<template v-else-if="isVisible('number', schema)">
<el-input :disabled="isDisabled(schema)" :prefix-icon="schema.attribute.icon || ''"
:placeholder="getPlaceholder(schema)" v-model.number="model[schema.column]"
:clearable="isSearch ? true : false">
<template v-if="schema.attribute.suffix" #append>
{{ schema.attribute.suffix }}
</template>
</el-input>
</template>
<template v-else>
<el-input :disabled="isDisabled(schema)" :prefix-icon="schema.attribute.icon || ''"
:placeholder="getPlaceholder(schema)" v-model.trim="model[schema.column]"
:clearable="isSearch ? true : false">
<template v-if="schema.attribute.suffix" #append>
{{ schema.attribute.suffix }}
</template>
</el-input>
</template>
</template>
<script setup>
import { onMounted, computed } from 'vue';
import Upload from './Upload.vue';
import { Calendar } from '@element-plus/icons-vue'
import { generateSchemaDescription } from '../libs/form';
import LevelSelect from './LevelSelect.vue';
const props = defineProps({
model: {
type: Object,
},
schema: {
type: Object,
},
scenario: {
type: String,
},
})
const isDropdownCreated = (schema) => {
if (props.scenario === 'search') {
return false;
}
let ret = schema.attribute.dropdown && schema.attribute.dropdown.created || false;
return ret;
}
const isDropdownFilterable = (schema) => {
if (props.scenario === 'search') {
return false;
}
return schema.attribute.dropdown && schema.attribute.dropdown.filterable || false;
}
const isDropdownDefaultFirst = (schema) => {
return schema.attribute.dropdown && schema.attribute.dropdown.default_first || false;
}
const isDropdownMulitLevel = (schema) => {
let values = schema.attribute.values;
if (Array.isArray(values)) {
for (let i in values) {
let row = values[i];
if (row.children && Array.isArray(row.children)) {
return true;
}
}
}
return false;
}
const isShowWordLimit = (schema) => {
return false
}
const isDisabled = (schema) => {
return schema.attribute.disable.indexOf(props.scenario) > -1 || schema.attribute.readonly.indexOf(props.scenario) > -1
}
const booleanValues = computed(() => {
return {
1: '是',
0: '否'
}
})
const maxLength = (schema) => {
if (schema.type === 'string') {
return schema.rule.max || 100
} else {
return 0
}
}
const isSearch = computed(() => {
return props.scenario === 'search';
})
const getIsDateDisable = (date) => {
if (props.schema.attribute.end_of_now) {
return date.getTime() > (new Date()).getTime();
}
return false
}
const isVisible = (type, schema) => {
let visible = false
switch (type) {
case 'number':
visible = (['integer', 'decimal'].indexOf(schema.format) > -1 || ['integer', 'double'].indexOf(schema.type) > -1)
break
case 'password':
visible = ['password', 'pass'].indexOf(schema.format) > -1
break
case 'time':
visible = ['time'].indexOf(schema.format) > -1
break
case 'date':
visible = ['date'].indexOf(schema.format) > -1
break
case 'datetime':
visible = ['datetime', 'timestamp'].indexOf(schema.format) > -1
break
case 'dropdown':
visible = ['dropdown', 'multiSelect'].indexOf(schema.format) > -1
break
case 'cascader':
visible = ['cascader'].indexOf(schema.format) > -1
break
case 'multistr':
if (props.scenario !== 'search') {
visible = ['textarea'].indexOf(schema.format) > -1
}
break
case 'color':
visible = ['color'].indexOf(schema.format) > -1
break
case 'file':
if (props.scenario !== 'file') {
if (['file'].indexOf(schema.format) > -1) {
if (schema.attribute.upload_url) {
visible = true;
}
}
}
break
case 'boolean':
if (
props.scenario !== 'search' &&
['bool', 'boolean'].indexOf(schema.format) > -1
) {
visible = true
} else {
visible = false
}
break
case 'search_boolean':
if (
props.scenario === 'search' &&
['bool', 'boolean'].indexOf(schema.format) > -1
) {
visible = true
} else {
visible = false
}
break
case 'string':
visible = ['string', 'text'].indexOf(schema.format) > -1
break
}
return visible
}
const getPlaceholder = (schema) => {
let placeholder = '';
if (schema.attribute.tooltip) {
placeholder = schema.attribute.tooltip
} else {
if (['dropdown'].indexOf(schema.format) > -1) {
placeholder = '请选择' + schema.label
} else {
placeholder = '请输入' + schema.label
}
}
return placeholder
}
const getStartPlaceholder = (schema) => {
let placeholder = '';
if (schema.attribute.tooltip) {
placeholder = schema.attribute.tooltip
} else {
placeholder = '起始' + schema.label
}
return placeholder
}
const getEndPlaceholder = (schema) => {
let placeholder = '';
if (schema.attribute.tooltip) {
placeholder = schema.attribute.tooltip
} else {
placeholder = '结束' + schema.label
}
return placeholder
}
const isRange = (schema) => {
return props.scenario === 'search'
}
onMounted(() => {
props.schema.attribute.description = generateSchemaDescription(props.schema, props.scenario)
})
</script>

View File

@ -0,0 +1,67 @@
<template>
<el-cascader v-model="value" :props="attributes" :options="options" :show-all-levels="false" @change="handleChange"
:clearable="clearable" />
</template>
<script setup>
import { ref, onMounted } from 'vue';
const value = ref([]);
const props = defineProps({
modelValue: {
type: String,
default: '',
},
options: {
type: Array,
default: [],
},
clearable: {
type: Boolean,
default: true,
}
})
const emit = defineEmits(['update:modelValue'])
const attributes = ref({
multiple: false
})
const handleChange = (e) => {
if (!Array.isArray(e)) {
emit('update:modelValue', '');
return
}
if (e.length > 1) {
emit('update:modelValue', e[e.length - 1]);
} else {
emit('update:modelValue', '');
}
}
const hasChildren = (v, vs) => {
if (!Array.isArray(vs)) {
return false;
}
for (let i in vs) {
if (vs[i].value == v) {
return true;
}
}
return false
}
onMounted(() => {
if (props.modelValue != '') {
for (let i in props.options) {
let row = props.options[i];
if (hasChildren(props.modelValue, row.children)) {
value.value = [row.value, props.modelValue];
break
}
}
}
})
</script>

View File

@ -0,0 +1,41 @@
<template>
<template v-if="isAllowed">
<router-link :to="to">
<slot name="default"></slot>
</router-link>
</template>
<span v-else>
<slot name="default"></slot>
</span>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import useUserStore from '@/stores/user';
const props = defineProps({
permission: {
type: String,
default: ''
},
to: {
type: String,
default: ''
}
})
const route = useRoute();
const isAllowed = ref(false);
onMounted(() => {
if (!props.permission) {
isAllowed.value = true;
} else {
let ps = route.name + '.' + props.permission
const { hasPermission } = useUserStore();
isAllowed.value = hasPermission(ps);
}
})
</script>

View File

@ -0,0 +1,64 @@
<template>
<el-upload drag :action="uploadUrl" :auto-upload="true" :limit="1" :headers="uploadHeaders" :show-file-list="false"
:on-success="onFileUploaded">
<span v-if="!isUploaded">{{ label }}</span>
<span v-else>
已上传
</span>
</el-upload>
</template>
<script setup>
import { ref, computed } from 'vue'
import { getBaseUrl } from '@/apis/request'
import useUserStore from '@/stores/user'
const userStore = useUserStore();
const props = defineProps({
action: {
type: String,
},
label: {
type: String,
default: "上传文件"
},
modelValue: {
type: String,
default: '',
}
})
const emit = defineEmits(['update:modelValue'])
const isUploaded = ref(false);
const uploadUrl = computed(() => {
let uri = props.action;
if (uri.indexOf('http') > -1) {
return uri;
} else {
return getBaseUrl() + uri;
}
})
const uploadHeaders = computed(() => {
return {
Authorization: userStore.getAccessToken(),
}
})
const onFileUploaded = (e) => {
console.log('upload response', e)
if (e.hasOwnProperty('code')) {
if (e.code === 0) {
isUploaded.value = true;
emit('update:modelValue', e.data.id);
}
}
if (e.hasOwnProperty('errno')) {
if (e.errno === 0) {
isUploaded.value = true;
emit('update:modelValue', e.result.id);
}
}
}
</script>

View File

@ -0,0 +1,52 @@
<template>
<div class="segment-action" v-if="isAllowed">
<el-text :type="type">
<el-tooltip v-if="title != ''" :content="title">
<icon :name="name"></icon>
</el-tooltip>
<icon v-else :name="name"></icon>
</el-text>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router';
import Icon from './Icon.vue';
import { ref, onMounted } from 'vue';
import useUserStore from '@/stores/user'
const props = defineProps({
name: {
type: String,
},
title: {
type: String,
default: '',
},
type: {
type: String,
default: ''
},
permission: {
type: String,
default: ''
}
})
const isAllowed = ref(true);
const route = useRoute();
onMounted(() => {
if (props.permission) {
let permission = props.permission;
if (permission.indexOf('.') === -1) {
permission = route.name + '.' + permission;
}
const { hasPermission } = useUserStore();
if (!hasPermission(permission)) {
isAllowed.value = false;
}
}
})
</script>

View File

@ -0,0 +1,126 @@
<template>
<div class="audio-element">
<div class="audio-toolbar">
<div class="audio-control">
<icon :name="iconName" :class="playing ? 'playing' : ''" @click="togglePlay"></icon>
</div>
<span class="audio-duration"> {{ audioDuration }}</span>
</div>
<el-progress @click="handleSeek" :percentage="percentage" :text-inside="true" :striped="true"
:show-text="false"></el-progress>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import Icon from './Icon.vue';
import { durationFormat } from '@/assets/js/formatter';
const props = defineProps({
url: {
type: String,
},
done: {
type: Function
},
autoPlay: {
type: Boolean,
default: false,
}
})
const playing = ref(false)
let audioElement = null;
let audioDuration = ref('');
let percentage = ref(0);
let loaded = ref(false);
const iconClass = computed(() => {
if (playing.value) {
return 'text-danger'
} else {
return ''
}
})
const iconName = computed(() => {
if (playing.value) {
return 'pause'
} else {
return 'play'
}
})
const togglePlay = (e) => {
try {
if (!audioElement.paused) {
audioElement.pause();
} else {
if (loaded.value) {
audioElement.play().then(res => {
playing.value = true;
}).catch(e => {
console.log('play error:' + e.message)
})
}
}
} catch (e) {
console.log(e);
}
}
const handleSeek = (e) => {
if (!loaded.value) {
return;
}
let x = e.offsetX;
let w = e.target.getBoundingClientRect().width;
let p = x / w;
let duration = audioElement.duration * p;
if (duration > 0 && duration < audioElement.duration) {
audioElement.currentTime = Math.floor(duration)
}
}
onMounted(() => {
audioElement = new Audio(props.url);
audioElement.addEventListener("loadeddata", () => {
loaded.value = true;
audioDuration.value = durationFormat(audioElement.currentTime, ':') + ' / ' + durationFormat(audioElement.duration, ':');
});
audioElement.addEventListener("canplaythrough", () => {
if (props.autoPlay) {
togglePlay();
}
});
audioElement.addEventListener("error", (e) => {
audioDuration.value = "加载失败"
})
audioElement.addEventListener("pause", () => {
playing.value = false;
});
audioElement.addEventListener("ended", () => {
if (props.done && typeof props.done === 'function') {
playing.value = false;
props.done();
}
});
audioElement.addEventListener('timeupdate', () => {
let ts = audioElement.currentTime;
percentage.value = parseInt((ts / audioElement.duration) * 100);
audioDuration.value = durationFormat(ts, ':') + ' / ' + durationFormat(audioElement.duration, ':');
})
})
onUnmounted(() => {
if (!audioElement.paused) {
audioElement.pause();
}
})
</script>

View File

@ -0,0 +1,92 @@
<template>
<div class="avatar-group" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<span v-if="!readonly" @click="handleAdd" class="avatar-plus"
:style="'--el-avatar-size:' + size + 'px'"></span>
<el-popover placement="top-start" :width="400" trigger="click">
<template #reference>
<div class="avatar-smaples">
<el-avatar v-for="item in userSample" :src="item.url" :title="item.user" :size="size"></el-avatar>
</div>
</template>
<div class="avatar-container">
<div class="avatar-list">
<el-avatar v-for="item in userLists" :src="item.url" :title="item.user" :size="size*1.2"></el-avatar>
</div>
<div class="avatar-footer"><span>已选择 : </span> {{ userLists.length }}</div>
</div>
</el-popover>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { getUserAvatar } from '@/apis/organize';
const props = defineProps({
users: {
type: Array
},
size: {
type: Number,
default: 20
},
max: {
type: Number,
default: 5
},
readonly: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['add', 'change'])
const showAll = ref(false)
const handleMouseEnter = (e) => {
showAll.value = true
}
const handleMouseLeave = (e) => {
showAll.value = false
}
const userSample = computed(() => {
let n = props.users.length;
let users = [];
if (props.max > 0 && n > props.max) {
n = props.max
}
for (let i = 0; i < n; i++) {
users.push({
url: getUserAvatar(props.users[i]),
user: props.users[i],
})
}
return users;
})
const userLists = computed(() => {
let n = props.users.length;
let users = [];
for (let i = 0; i < n; i++) {
users.push({
url: getUserAvatar(props.users[i]),
user: props.users[i],
})
}
return users;
})
const handleAdd = (e) => {
emit('add')
}
onMounted(() => {
watch(() => { return props.users }, (val) => {
emit('change', val)
}, { deep: true })
})
</script>

View File

@ -0,0 +1,44 @@
<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

@ -0,0 +1,28 @@
<template>
<i class="iconfont" :class="className"></i>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
name: {
type: String,
},
type: {
type: String,
default: '',
},
title: {
type: String,
default: '',
}
})
const className = computed(() => {
if (props.name.indexOf('icon-') > -1) {
return props.name
} else {
return 'icon-' + props.name
}
})
</script>

View File

@ -0,0 +1,104 @@
<template>
<skeleton :module-name="moduleName" :table-name="tableName" @ready="handleReady">
<template #default>
<section v-if="isReady" class="upload-container">
<div class="upload-panel" v-if="!uploadSuccess">
<el-upload drag :action="uploadUrl" :show-file-list="false" :on-success="onUploadSuccess">
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖动文件到此或 <em>点击上传</em>
</div>
</el-upload>
<div class="outbound-download-template">
<span @click="handleDownloadTemplate"><em>点击</em>下载</span>
</div>
</div>
<div class="upload-success" v-else>
<el-result icon="success" title="上传成功" sub-title=", "></el-result>
</div>
</section>
</template>
</skeleton>
</template>
<style lang="scss">
.upload-container {
.outbound-download-template {
margin-top: 1rem;
text-align: right;
span {
font-size: .7rem;
color: var(--el-text-color-muted);
cursor: pointer;
em {
text-decoration: none;
font-style: normal;
color: var(--el-color-primary);
}
}
}
}
</style>
<script setup>
import { ref, computed } from 'vue';
import { getBaseUrl } from '@/apis/request';
import useUserStore from '@/stores/user';
import Skeleton from '@/components/fragment/Skeleton.vue';
import queryString from 'query-string';
import { UploadFilled } from '@element-plus/icons-vue';
let crud = null;
const props = defineProps({
moduleName: {
type: String,
default: ''
},
tableName: {
type: String,
default: '',
},
fields: {
type: Array
}
})
const isReady = ref(false);
const uploadSuccess = ref(false);
const handleReady = (e) => {
isReady.value = true;
crud = e
}
const uploadUrl = computed(() => {
let baseUri = getBaseUrl();
let { getAccessToken } = useUserStore()
let ps = {
'access_token': getAccessToken(),
'_': (new Date()).getTime()
};
//
for (let i in props.fields) {
let row = props.fields[i];
if (!row.column) {
continue;
}
ps['_attr_' + row.column] = row.value;
}
return baseUri + crud.value.__buildModelUri(props.moduleName, props.tableName, 'import') + "?" + queryString.stringify(ps);
})
const handleDownloadTemplate = (e) => {
crud.value.downloadResourceTemplate(props.moduleName, props.tableName);
}
const onUploadSuccess = (response) => {
uploadSuccess.value = true;
}
</script>

View File

@ -0,0 +1,39 @@
<template>
<el-divider class="loading-more">
<template v-if="running">
<el-icon class="is-loading">
<loading />
</el-icon>
</template>
<template v-else>
<span @click="loadingMore"></span>
</template>
</el-divider>
</template>
<script setup>
import { ref } from 'vue';
import { Loading } from '@element-plus/icons-vue';
const props = defineProps({
callback: {
type: Function,
default: () => {
return Promise.resolve();
}
}
})
const running = ref(false)
const loadingMore = () => {
if (typeof props.callback === 'function') {
running.value = true
props.callback().then(res => {
running.value = false;
}).catch(e => {
running.value = false;
})
}
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<template v-if="hasChildren(menu.children)">
<el-sub-menu :index="menu.route">
<template #title>
<icon v-if="menu.icon" :name="menu.icon"></icon>
<span>{{ menu.label }}</span>
</template>
<template v-for="child in menu.children">
<menu-item v-if="!menu.hidden" :menu="child"></menu-item>
</template>
</el-sub-menu>
</template>
<template v-else>
<el-menu-item :index="menu.route" v-if="!menu.hidden">
<icon v-if="menu.icon" :name="menu.icon"></icon>
<span>{{ menu.label }}</span>
</el-menu-item>
</template>
</template>
<script setup>
import Icon from './Icon.vue'
const props = defineProps({
menu: {
type: Object
}
})
/**
* 检查是否有可用的子组件
* @param {Array} vs
*/
const hasChildren = (vs) => {
if (!Array.isArray(vs)) {
return false;
}
if (vs.length <= 0) {
return false;
}
let invisible = 0;
for (let v of vs) {
if (v.hidden) {
invisible++
}
}
return invisible < vs.length
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<span class="percentage-label" v-if="text.length > 0">{{ text }}</span>
<template v-if="colorful">
<el-text :type="displayValue == 0 ? '' : (displayValue < critical ? 'danger' : 'success')">
{{ displayValue }}%
</el-text>
</template>
<template v-else>
{{ displayValue }}%
</template>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
text: {
type: String,
default: '',
},
value: {
type: Number,
default: 0
},
total: {
type: Number,
default: 0
},
colorful: {
type: Boolean,
default: true,
},
critical: {
type: Number,
default: 80,
}
})
const displayValue = computed(() => {
if (props.total === 0 || props.value === 0) {
return 0;
}
let v = ((props.value / props.total) * 100).toFixed(2);
v = parseFloat(v);
return isNaN(v) ? 0 : v;
})
</script>

View File

@ -0,0 +1,57 @@
<template>
<div class="preview-item">
<div class="preview-item-label">
<slot name="label" :label="label">
<label class=" text-muted"> {{ label }}</label>
</slot>
</div>
<div class="preview-item-value">
<slot name="value" :label="label">
<span>{{ value }}</span>
</slot>
</div>
</div>
</template>
<script setup>
const props = defineProps({
label: {
type: [String, Number]
},
value: {
type: [String, Number]
}
})
</script>
<style lang="scss">
.preview-item {
overflow: hidden;
font-size: .9rem;
margin-bottom: .68rem;
clear: both;
&::before,
&::after {
content: '';
clear: both;
}
.preview-item-label {
float: left;
margin-right: 1em;
min-width: 4.2em;
text-align: justify;
text-align-last: justify;
}
.preview-item-value {
display: block;
overflow: hidden;
word-break: normal;
word-wrap: break-word;
text-align: right;
}
}
</style>

View File

@ -0,0 +1,37 @@
<template>
<span class="colorful-tag" :style="{ color: textColor, backgroundColor: backgroundColor }">
<slot name="default">
{{ text }}
</slot>
</span>
</template>
<style lang="scss">
.colorful-tag {
display: inline-block;
font-size: .78rem;
padding: .12rem .6rem;
border-radius: 20px;
}
</style>
<script setup>
import { computed } from 'vue';
import Color from 'color';
const props = defineProps({
text: {
type: String,
},
color: {
type: String,
}
})
const textColor = computed(() => {
return props.color
})
const backgroundColor = computed(() => {
return Color(props.color).fade(0.8).hsl()
})
</script>

View File

@ -0,0 +1,72 @@
<template>
<el-cascader v-model="value" :options="options" :show-all-levels="false" @change="handleChange" :clearable="clearable" filterable/>
</template>
<script setup>
import { getDepartmentUserNested } from '@/apis/organize'
import { ref, onMounted } from 'vue';
const value = ref([]);
const options = ref([]);
const props = defineProps({
modelValue: {
type: String,
default: '',
},
clearable: {
type: Boolean,
default: true,
}
})
const emit = defineEmits(['update:modelValue'])
const hasUser = (uid, users) => {
if (!Array.isArray(users)) {
return false;
}
for (let i in users) {
if (users[i].value == uid) {
return true;
}
}
return false
}
const handleChange = (e) => {
if(!Array.isArray(e)){
emit('update:modelValue', '');
return
}
if (e.length > 1) {
emit('update:modelValue', e[e.length - 1]);
} else {
emit('update:modelValue', '');
}
}
onMounted(() => {
getDepartmentUserNested().then(res => {
options.value = res;
for (let i in options.value) {
if (!Array.isArray(options.value[i].children) || options.value[i].children.length == 0) {
options.value[i].disabled = true;
}
}
if (props.modelValue != '') {
for (let i in res) {
let row = res[i];
if (hasUser(props.modelValue, row.children)) {
value.value = [row.value, props.modelValue];
break
}
}
}
}).catch(e => {
})
})
</script>

View File

@ -0,0 +1,239 @@
<template>
<div class="d-flex user-pick " :style="{ 'height': contentHeight }">
<div class="department-panel">
<el-tree :data="departments" @node-click="handleChangeDepartment" />
</div>
<div class="user-panel">
<template v-if="users.length > 0">
<div class="user-panel-action">
<el-checkbox v-model="isSelectedAll" :indeterminate="isIndeterminate"
@change="handleToggleAll">全选</el-checkbox>
</div>
<div v-for="item in users" class="user-pick-user d-flex align-center"
:class="item.selected ? 'active' : ''">
<div class="flex-shrink" @click="handleUserClicked(item)">
<el-avatar :size="26" :src="item.avatar" :title="item.username"></el-avatar>
</div>
<div class="flex-fill" @click="handleUserClicked(item)">
<span class="px-1">{{ item.username }} ({{ item.uid }})</span>
</div>
<div class="flex-shrink">
<el-checkbox v-model="item.selected" @change="handleUserChecked(item)"></el-checkbox>
</div>
</div>
</template>
<template v-else>
<el-empty></el-empty>
</template>
</div>
</div>
<div class="user-pick-status"><span>已选择:</span>{{ props.modelValue.length }}</div>
</template>
<style lang="scss">
.user-pick-status {
padding-top: .68rem;
font-size: .8rem;
text-align: right;
span {
margin-right: .5rem;
}
}
.user-pick {
--user-pick-border: var(--el-border-color-lighter);
box-sizing: border-box;
border: 1px solid var(--user-pick-border);
border-radius: 6px;
.flex-shrink {
height: 100%;
}
.user-panel {
box-sizing: border-box;
padding: 1rem;
height: 100%;
flex: 1 1 auto !important;
overflow-y: auto;
.user-panel-action {
text-align: right;
margin-bottom: 1rem;
}
.user-pick-user {
box-sizing: border-box;
padding: .2rem .5rem;
cursor: pointer;
color: var(--el-text-color-muted);
&.active {
color: var(--el-text-color-primary);
}
}
}
.department-panel {
box-sizing: border-box;
padding: 1rem 0rem;
background-color: var(--el-fill-color);
min-width: 120px;
height: 100%;
flex-shrink: 0;
--el-fill-color-blank: transparent;
li {
box-sizing: border-box;
padding: .38rem 1.5rem;
cursor: pointer;
transition: all .3s ease;
&:hover,
&.active {
color: var(--el-text-color-primary);
span {
color: var(--el-text-color-primary);
}
}
i {
font-size: 1rem;
}
span {
font-size: .9rem;
color: var(--el-text-color-muted);
}
}
}
}
</style>
<script setup>
import { getNestedDepartments, getDepartmentUsers } from '@/apis/organize'
import { getBaseUrl } from '@/apis/request';
import { computed, onMounted, ref } from 'vue';
import Icon from './Icon.vue';
const props = defineProps({
height: {
type: [String, Number],
default: 400,
},
modelValue: {
type: Array,
default: []
}
})
const emit = defineEmits(['change', 'update:modelValue'])
const loading = ref(false)
const departments = ref([])
const users = ref([])
const isIndeterminate = ref(false);
const isSelectedAll = ref(false);
const activeDepartment = ref('');
const contentHeight = computed(() => {
if (typeof props.height === 'number') {
return props.height + 'px'
}
return props.height
})
const changeUser = (e) => {
let selected = props.modelValue;
if (e.selected) {
if (selected.indexOf(e.uid) === -1) {
selected.push(e.uid);
}
} else {
let pos = selected.indexOf(e.uid);
if (pos > -1) {
selected.splice(pos, 1)
}
}
if (selected.length == users.value.length || selected.length == 0) {
isIndeterminate.value = false
} else {
isIndeterminate.value = true
}
if (selected.length == users.value.length) {
isSelectedAll.value = true
}
emit('change', e)
emit('update:modelValue', selected);
}
const handleUserClicked = (e) => {
e.selected = !e.selected;
changeUser(e)
}
const handleUserChecked = (e) => {
changeUser(e)
}
const handleToggleAll = (e) => {
if (isSelectedAll.value) {
for (let i in users.value) {
users.value[i].selected = true;
changeUser(users.value[i]);
}
} else {
for (let i in users.value) {
users.value[i].selected = false;
changeUser(users.value[i]);
}
}
}
const handleChangeDepartment = (e) => {
loading.value = true;
activeDepartment.value = e.value;
getDepartmentUsers(e.value).then(res => {
users.value = []
isSelectedAll.value = false;
for (let i in res) {
let row = res[i];
if (row.avatar.indexOf('//') == -1) {
row.avatar = getBaseUrl() + row.avatar;
}
if (props.modelValue.indexOf(row.uid) > -1) {
row.selected = true
} else {
row.selected = false;
}
users.value.push(row);
}
if (props.modelValue.length == 0 || props.modelValue.length == users.value.length) {
isIndeterminate.value = false
} else {
isIndeterminate.value = true
}
if (props.modelValue.length == users.value.length) {
isSelectedAll.value = true
}
loading.value = false;
}).catch(e => {
users.value = []
loading.value = false;
})
}
onMounted(() => {
getNestedDepartments().then(res => {
departments.value = res;
})
})
</script>

View File

@ -0,0 +1,708 @@
const menu = [
{
label: "控制面板",
icon: "dashboard",
hidden: false,
route: "/dashboard",
children: [
{
label: "字段配置",
hidden: true,
access: 'allow',
route: "/setting/schemas"
},
{
label: "消息通知",
hidden: true,
access: 'allow',
route: "/organize/user/notice"
}
]
},
{
label: "组织架构",
icon: "org",
hidden: false,
route: '/organizers',
children: [
{
label: "个人设置",
hidden: true,
access: 'allow',
route: "/organize/user/profile"
},
{
label: "角色管理",
route: "/organize/roles",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "部门管理",
route: "/organize/departments",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "座席管理",
route: "/organize/users",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
]
},
{
label: "客户管理",
icon: "group",
hidden: false,
route: "/customer",
children: [
{
label: "联系人",
navigation: true,
route: "/customer/contacts",
children: [
{
label: "新建联系人",
hidden: true,
route: "/customer/contact/create",
view: '../views/customer/contact/Form.vue'
},
{
label: "编辑联系人",
hidden: true,
route: "/customer/contact/update",
view: '../views/customer/contact/Form.vue'
},
{
label: "联系人详情",
hidden: true,
route: "/customer/contact/view",
view: '../views/customer/contact/View.vue'
},
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
},
{
label: '详情',
value: 'view'
}
]
}
]
},
{
label: "呼叫任务",
icon: "outbound",
hidden: false,
route: "/outbounds",
children: [
{
label: "全部任务",
hidden: false,
navigation: true,
route: "/outbound/campaigns",
children: [
{
label: "新建任务",
hidden: true,
route: "/outbound/campaign/create",
view: '../views/outbound/campaign/Form.vue'
},
{
label: "编辑任务",
hidden: true,
route: "/outbound/campaign/update",
view: '../views/outbound/campaign/Form.vue'
},
{
label: "任务详情",
hidden: true,
route: "/outbound/campaign/view",
view: '../views/outbound/campaign/View.vue'
},
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
},
{
label: '详情',
value: 'view'
},
{
label: '启动',
value: 'start'
},
{
label: '暂停',
value: 'pause'
},
{
label: '停止',
value: 'stop'
},
{
label: '导入',
value: 'import'
}
]
}
]
},
{
label: "记录报表",
icon: "data",
route: "/record",
children: [
{
label: "话单记录",
route: "/record/cdrs",
children: [
{
label: "通话记录",
route: "/record/cdr/logs"
},
{
label: "来电未接",
route: "/record/cdr/noanswers"
},
{
label: "座席未接",
route: "/record/cdr/usernoanswers"
},
]
},
{
label: "话单报表",
route: "/record/reports",
children: [
{
label: "座席报表",
route: "/record/report/users"
},
{
label: "呼入报表",
route: "/record/report/inbounds"
},
]
},
{
label: "日志记录",
route: "/record/log",
children: [
{
label: "登录记录",
route: "/record/log/logins"
},
{
label: "考勤记录",
route: "/record/log/attendances"
},
{
label: "操作记录",
route: "/record/log/activities"
},
{
label: "导入导出",
route: "/record/log/genfiles"
},
]
},
]
},
{
label: "状态监控",
icon: "jiankong",
route: "/monitor",
children: [
{
label: "座席监控",
route: "/monitor/users",
},
{
label: "话务监控",
route: "/monitor/calls",
},
{
label: "系统监控",
hidden: true,
route: "/monitor/systems",
},
]
},
{
label: "系统配置",
icon: "set",
hidden: false,
route: "/settings",
children: [
{
label: "匹配规则",
route: "/setting/rules",
children: [
{
label: "号码匹配",
navigation: true,
route: "/setting/rule/numbersets",
children: [
{
label: "号码列表",
hidden: true,
route: "/setting/rule/numberset/list"
}
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "正则匹配",
route: "/setting/rule/regexps",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "时间匹配",
route: "/setting/rule/timeslots",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "地区匹配",
route: "/setting/rule/zones",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
}
]
},
{
label: "应用配置",
route: "/setting/destinations",
children: [
{
label: "语言导航",
navigation: true,
route: "/setting/destination/ivrs",
children: [
{
label: "新建技能组",
hidden: true,
route: "/setting/destination/ivr/create",
view: '../views/setting/destination/ivr/Form.vue'
},
{
label: "编辑技能组",
hidden: true,
route: "/setting/destination/ivr/update",
view: '../views/setting/destination/ivr/Form.vue'
},
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "呼叫队列",
navigation: true,
route: "/setting/destination/queues",
children: [
{
label: "新建队列",
hidden: true,
route: "/setting/destination/queue/create",
view: '../views/setting/destination/queue/Form.vue'
},
{
label: "编辑队列",
hidden: true,
route: "/setting/destination/queue/update",
view: '../views/setting/destination/queue/Form.vue'
},
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "语言文件",
route: "/setting/destination/sounds",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "脚本程序",
route: "/setting/destination/scripts",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "快捷功能",
route: "/setting/destination/shortcuts",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "网关管理",
navigation: true,
route: "/setting/destination/gateways",
children: [
{
label: "新建网关",
hidden: true,
route: "/setting/destination/gateway/create",
view: '../views/setting/destination/gateway/Form.vue'
},
{
label: "编辑网关",
hidden: true,
route: "/setting/destination/gateway/update",
view: '../views/setting/destination/gateway/Form.vue'
},
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "设备列表",
route: "/setting/destination/devices",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
]
},
{
label: "路由配置",
route: "/setting/routes",
children: [
{
label: "呼入路由",
route: "/setting/route/inbounds",
navigation: true,
children: [
{
label: "新增路由",
hidden: true,
route: "/setting/route/inbound/create",
view: '../views/setting/route/inbound/Form.vue'
},
{
label: "更新路由",
hidden: true,
route: "/setting/route/inbound/update",
view: '../views/setting/route/inbound/Form.vue'
},
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "呼出路由",
route: "/setting/route/outbounds",
navigation: true,
children: [
{
label: "新增路由",
hidden: true,
route: "/setting/route/outbound/create",
view: '../views/setting/route/outbound/Form.vue'
},
{
label: "更新路由",
hidden: true,
route: "/setting/route/outbound/update",
view: '../views/setting/route/outbound/Form.vue'
},
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
]
},
{
label: "高级设置",
route: "/setting/advanced",
children: [
{
label: "访问控制",
navigation: true,
route: "/setting/advanced/acls",
children: [
{
label: "规则列表",
hidden: true,
route: "/setting/advanced/acl/list"
}
],
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "变量设置",
route: "/setting/advanced/variables",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "状态设置",
route: "/setting/advanced/statuses",
permissions: [
{
label: '新建',
value: 'create'
},
{
label: '更新',
value: 'update'
},
{
label: '删除',
value: 'delete'
}
]
},
{
label: "字段配置",
hidden: true,
route: "/setting/schemas",
},
]
},
]
},
];
export default menu;

View File

@ -0,0 +1,26 @@
import useUserStore from '@/stores/user'
const installPermission = (app) => {
app.directive("permission", {
mounted: function (el, binding) {
const { hasPermission } = useUserStore();
let permission = binding.value;
if (typeof permission !== 'string') {
return;
}
if (permission.indexOf('.') === -1) {
if (app.config.globalProperties && app.config.globalProperties.$route) {
permission = app.config.globalProperties.$route.name + '.' + permission;
}
}
if (!hasPermission(permission)) {
if (el.parentNode) {
el.parentNode.removeChild(el)
}
}
},
});
};
export default {
install: installPermission
}

View File

@ -0,0 +1,4 @@
const messages = {}
export default messages

View File

@ -0,0 +1,3 @@
export default {
}

View File

@ -0,0 +1,3 @@
export default {
}

View File

@ -0,0 +1,175 @@
<template>
<div class="d-flex header-wrapper" :style="{ backgroundColor: headerBackgroundColor }">
<div class="logo flex-fill">
<img :src="logoUrl" class="hidden-sm-and-down" :title="productName" />
<icon name="icon-unorderedlist" class="hidden-md-and-up" @click="handleToggleMenuVisible"></icon>
</div>
<div class="flex-shrink">
</div>
<div class="flex-shrink">
<ul class="list list-inline">
<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>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN">简体中文</el-dropdown-item>
<el-dropdown-item command="en-US">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</li>
<li>
<el-dropdown @command="handleMenuCommand">
<div class="header-avatar">
<el-avatar :size="36" :title="username" :src="avatar">
</el-avatar>
<i class="user-status" :style="{ backgroundColor: userStateColor }" :title="userStateText"
@click="dialogVisible = true"></i>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="status">设置状态</el-dropdown-item>
<el-dropdown-item command="profile">个人设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出系统</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</li>
</ul>
</div>
</div>
<el-dialog class="status-dialog" v-model="dialogVisible" width="320px" draggable>
<div class="current-status">
<div>
<i :style="{ backgroundColor: userStateColor }"></i> <span>{{ userStateText }}</span>
</div>
</div>
<el-row :gutter="10">
<el-col :span="8" v-for="item in userStatus">
<div class="status-item" @click="handleSetStatus(item)"
:class="item.value === userState ? 'active' : ''">
<div class="status-avatar">
<i :style="{ backgroundColor: item.color }"></i>
</div>
<div class="status-text">{{ item.label }}</div>
</div>
</el-col>
</el-row>
</el-dialog>
</template>
<script setup>
import { computed, h, inject, onMounted, onUnmounted, ref } from 'vue';
import useSystemStore from '@/stores/system'
import useThemeStore from '@/stores/theme'
import useUserStore from '@/stores/user'
import { storeToRefs } from 'pinia';
import screenfull from 'screenfull';
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 { logoUrl, productName } = storeToRefs(systemStore);
const { headerBackgroundColor } = storeToRefs(themeStore);
const { uid, avatar, username, unreadMsgCount } = storeToRefs(userStore);
const logout = inject('logout');
const router = useRouter()
const userStatus = computed(() => {
return getUserStatus();
})
const userStateText = computed(() => {
return getStatusText(userState.value)
})
const userStateColor = computed(() => {
return getStatusTextColor(userState.value)
})
const handleFullscreen = (e) => {
if (screenfull.isEnabled) {
screenfull.toggle();
}
}
const handleListNotices = (e) => {
let uid = userStore.uid;
router.push(`/organize/user/notice?receiver=${uid}&read=no`)
}
const handleChangeLang = (e) => {
systemStore.setLanguage(e)
}
const handleToggleMenuVisible = (e) => {
systemStore.toggleFlowSidebarVisible()
}
const handleSetStatus = (e) => {
}
const handleMenuCommand = (e) => {
switch (e) {
case 'status':
dialogVisible.value = true;
break;
case 'profile':
router.push('/organize/user/profile')
break
case 'logout':
logout();
break
}
}
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(() => {
})
</script>

View File

@ -0,0 +1,107 @@
<template>
<el-skeleton animated :loading="loading" :throttle="2000">
<template #template>
<div class="skeleton" v-loading="loading"></div>
</template>
<el-container class="page" :style="{ backgroundColor: backgroundColor }" v-if="!loading">
<el-header>
<headerbar></headerbar>
</el-header>
<el-container class="container">
<el-aside :width="sidebarWidth + 'px'" :class="collapse ? 'collapse' : ''"
:style="{ backgroundColor: sidebarBackgroundColor }" class="hidden-sm-and-down">
<sidebar></sidebar>
</el-aside>
<el-drawer v-model="flowSidebarVisible" class="hidden-md-and-up sidebar-drawer" direction="ltr"
:size="220" :style="{ backgroundColor: sidebarBackgroundColor }">
<sidebar></sidebar>
</el-drawer>
<el-container>
<el-main>
<viewer></viewer>
</el-main>
<el-footer class="hidden-sm-and-down">
<statusbar></statusbar>
</el-footer>
</el-container>
</el-container>
</el-container>
</el-skeleton>
</template>
<script setup>
import Headerbar from './Headerbar.vue'
import Sidebar from './Sidebar.vue'
import Viewer from './Viewer.vue'
import Statusbar from './Statusbar.vue'
import { useRouter } from 'vue-router'
import { ref, onMounted, onUnmounted, provide, nextTick } from 'vue'
import { storeToRefs } from 'pinia';
import useThemeStore from '@/stores/theme'
import useSystemStore from '@/stores/system'
import useUserStore from '@/stores/user'
import { getBaseHost } from '@/apis/request'
import { updateStatusMap } from '@/assets/js/status'
import { userLogout, getUserProfile } from '@/apis/organize'
import { getConfigure } from '@/apis/system'
const themeStore = useThemeStore()
const systemStore = useSystemStore()
const viewVisible = ref(true)
const { productName, collapse, sidebarWidth, flowSidebarVisible } = storeToRefs(systemStore);
const { backgroundColor, sidebarBackgroundColor } = storeToRefs(themeStore);
const loading = ref(false)
const router = useRouter()
const redirectTo = (uri) => {
viewVisible.value = false;
nextTick(() => {
if (typeof uri === 'string') {
if (uri[0] === '/') {
uri = uri.substring(1)
}
}
router.push(`/redirect/${uri}`)
viewVisible.value = true
})
}
provide("redirectTo", redirectTo);
const logout = () => {
userLogout().then(res => {
const { setAccessToken } = useUserStore()
setAccessToken('', 0);
nextTick(() => {
router.push('/login')
})
}).catch(e => { })
}
provide('logout', logout)
onMounted(() => {
document.title = productName.value
let schema = window.location.protocol == 'http:' ? "ws://" : 'wss://'
//
getConfigure().then(res => {
const { setAttributeValue } = useSystemStore();
for (let i in res) {
let row = res[i];
setAttributeValue(row.attribute, row.value);
}
}).catch(e => {
console.log(e);
router.push('/login');
})
})
onUnmounted(() => {
})
</script>

View File

@ -0,0 +1,82 @@
<template>
<div class="d-flex aside-wrapper">
<div class="flex-fill">
<el-menu default-active="2" :background-color="sidebarBackgroundColor" :text-color="sidebarTextColor"
:active-text-color="sidebarActiveTextColor" :router="true" :collapse="collapse">
<template v-for="menu in rawMenus">
<menu-item v-if="!menu.hidden" :menu="menu"></menu-item>
</template>
</el-menu>
</div>
<div class="flex-shrink drag-bar" :class="dragging ? 'active' : ''" @mousedown="handleDragStart">
</div>
</div>
</template>
<script setup>
import MenuItem from '@/components/widgets/MenuItem.vue'
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import useThemeStore from '@/stores/theme'
import useSystemStore from '@/stores/system'
import useUserStore from '@/stores/user'
import { filterMenus,getMenus } from '@/assets/js/menu'
const themeStore = useThemeStore();
const systemStore = useSystemStore();
const userStore = useUserStore();
const { sidebarBackgroundColor, sidebarTextColor, sidebarActiveTextColor } = storeToRefs(themeStore);
const { collapse, sidebarWidth } = storeToRefs(systemStore)
const dragging = ref(false)
const navbarOffset = ref(0)
const asideBeforeDragWidth = ref(0)
const rawMenus = computed(() => {
const { permissions } = userStore
return filterMenus(permissions);
})
const handleDragStart = (e) => {
dragging.value = true
navbarOffset.value = e.clientX
asideBeforeDragWidth.value = sidebarWidth.value
}
const handleDragging = (e) => {
if (!dragging.value) {
return
}
let offset = e.clientX - navbarOffset.value;
sidebarWidth.value = asideBeforeDragWidth.value + offset;
if (sidebarWidth.value < 68 && offset < 0) {
collapse.value = true;
dragging.value = false;
sidebarWidth.value = 68;
}
if (sidebarWidth.value > 100 && offset > 0) {
if (collapse.value) {
collapse.value = false
}
}
}
const handleDragged = (e) => {
dragging.value = false
}
onMounted(() => {
document.addEventListener('mousemove', handleDragging)
document.addEventListener('mouseup', handleDragged)
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleDragging)
document.removeEventListener('mouseup', handleDragged)
})
</script>

View File

@ -0,0 +1,10 @@
<template>
<div class="d-flex align-center">
<div class="flex-shrink">
</div>
<div class="flex-fill"></div>
</div>
</template>
<script setup>
</script>

View File

@ -0,0 +1,6 @@
<template>
<RouterView></RouterView>
</template>
<script setup>
import { RouterView } from 'vue-router'
</script>

View File

@ -0,0 +1,345 @@
<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

@ -0,0 +1,30 @@
<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

@ -0,0 +1,229 @@
<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,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAYAAAA5ZDbSAAAAAXNSR0IArs4c6QAACCJJREFUeAHtnUlvFEcUx1/P5rHx7vEy3o3B2EzCkoUTpxws8T245MoHyS3iwveI5APKgRMKEkSxkJAiEtZgDIF4ADMwTOrf0NHMZLaa7qrq17wnjQb3dNd79f911avqrm48arJr12+WqpXqRbV5u0a0TFQbbtpF/oyVAl7ZI7qnQtpJ59JXzp87s1sfnvrto+3u7ub2/z78gTz6vlarpYLt8s1HAc/zPlCNLhcm8pdKpVIFkfuAfbgvDn9SYL/jUx2JtJ0CCvTVwnj+AiD7LRUtV+C2k4vfdrD0e2MVuufn3HfVX6Vb5geyU8TortPZ9KkMBlQ1kpzbSSyOv6HBgi266G2OFZCYe1JgO/VxKtTTzrITMwXAVrVgmecy46YRbm3YH0VrHCG7MlNAADMDphuuANZVjNn+ApgZMN1wBbCuYsz2F8DMgOmGK4B1FWO2vwBmBkw3XAGsqxiz/QUwM2C64QpgXcWY7S+AmQHTDVcA6yrGbH8BzAyYbrgCWFcxZvtnmMWrHe7oyDAVpsZpZHiIcrksDagP7G3lHVXU56D8mvafvaB/DsraZXM4IJGA1YIzWpyfUZ9ZH2orEEODaRoazNP42AgtLcz6sB88ekIPHu2RWs/U6hCW2xIHGK11fW2J8gM5LSBo3UdXF2m+OEO/373vt2qtAmK6c6Jy8OryPJU217Xh1rPBiYEyUFYSLDGAtzbWaGWpGBkTlIUyuVsiAKO1zUxPRs4CZXJvyewBI+dG2XKbzxKUDR9cjTVgjJYxoDJt8AFfHI01YEyFdEfL/UCCD/jiaMwBz1rTHHNqjsYWMK5QYe5qy+ALPrkZW8AuBj4ufIY9odgCxrVl2+bCZ9g6sgVss3sORHbhM/Dd7zdbwMFdoX4r3s9xLnz2E2f9MWwB11fC1r853mRiCxj3c21b5Z19n2HryBYwbtbbNhc+w9aRLWCsxLBtB+VXtl2G9scWMJbZ2Lb9Zy9tuwztjy1grKGy2WXCF8d1W2wB49TGGipbZtNXlHViDniPDt/679yMUpP/lQUfWIzH0VgDxupHLJAzbfDBdaUla8AAi8HWn/cfG2OMsl0M6KKqEHvAEOKPe49o7+nzqDT5rxyUibI5WyIAA8DtO3cjbclouSiTuyVq4TtaW/nV674WvgcgMaBK0sL3RAEGJOTLZ89fdn10JQAafGOeK4+uBGrE/Bsj3vsPn/gfefgs5rDChoerTxyvQIWtd3B8YgZZQYXku1EBAdyoR+L+EsCJQ9pYIbaAUymP5mYL9PWZLZoYH22sVYR/DQ3lKZ1OR1ii3aLYTZOwsnGhOE3F2WnKZj+Gv7mxSjdu3o789iF8nf5ig1Jeih4+3vOnUe/fV+0SCunN+/naLyzeV5BKpfynCPG6hVYPgr14eUC3frsTUo7GwwEXr3gIrFqtKtBP1fTrL+ICmkUXjS74m7MnaXlxriVcAACIKJ/lRVn1cOEDXTVi+PZsiSYnxrAp9hZrwJlMhjaPr9Kp0nEazA90FRPiR5GPUQbKamfour88eYw21pcJPUucLbbRDao34Hx1epNmZ6Z61g9dN/JxmCcQcCzKaJUGmgMpzk37PUucH2mJJeCx0WE6e+pET622WfRcNhvq3Rp4LwfK6NXQszTn6l6PtbFf7ABPFyb8Ljmruud+rd983Crv9hIDcjO67MJk/F71ECvAmNeiBUWR13Tzcbe82w00Yj65eVQrpXQrM4rfYwMYrQ6Dll5yXy8V18nHOnm3k2/4PHFshaZi1JJjARgDqpI6+6OCG0DoNR/r5t2g/Fbf/omlRv54TWIczDlgTIWQv/Btwrrl437zbqdYM5k0lbbWY3GJ0zngLTUl6WWO20nQbr+1y8dh824nv2jBcXhTnlPAGDHbuCLUKh9HlXc7QZ6aHKM5jXl8p7L6/c0Z4HQ6Revq7a62rDkfR5l3O9VhbWVBddXOZCZnnleXF2hA85W/nYTs5bcgH5vIu+38o6dYWXL35lozI5t2tf20/cjQoH/Lr8tuRn7udI3ZiENV6IJ6B/XjJ/v05s2hKRdty3XSghfb3PJrG2WEPyAfRz0d6xYeFiesqbtTLsw6YEwhZtTg6nMzXPwIFijYrLt1wEV1OTKKS5E2RYrCl7/EaKYQRVFaZVgHPK9usX2uVpxLOGBcWMj3cOM+qScALuhgJG/TrLbgiXG7lbMpZK++bN+IsAp4bFQAYzGDTbMGGAOrOC9tsSX68JFBq4NMa4Bx5tqef9qCpuMHGoyNHNE5JNS+VgGHijRBB49a7KatAbZ93TnO54Pp26P1dbcGOMwiuvqAk/Bvm1e07AH+9BxREgCFrYMADqtgzI+32ZvZa8GG1lzFnGXL8Gy2YGv3g2/cut2ysrLRrALWAB8evjVbEym9pQLWuuiW3mWjcQUEsHGJ3ToQwG71N+5dABuX2K0DAexWf+PeBbBxid06EMBu9TfuXQAbl9itAwHsVn/j3gWwcYndOhDAbvU37l0AG5fYrQMB7FZ/494FsHGJ3ToQwG71N+5dABuX2K0DAexWf+PeBbBxid06EMBu9TfuXQAbl9itAwXYK7sNQbybU8Arq/e/0D1zDqRklwqALbroHZdBiG+jCuyk0rn0FfXM6gejbqRw6wqAKdimzp87s0s1umw9AnFoVgHFFGz9UXRhIn9JEb9q1qOUbksBsART+PMBl0qlSmE8f0G95O9H6a5tYYjeD9iBIViCKTyogVajXbt+s1StVC+qrdvq/7xbJqrZfS1MYzjyV1cFvPKnmdAOcq6fcuuO+RcbTpTXEDYkmgAAAABJRU5ErkJggg==">
</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>

23
web/src/main.js 100644
View File

@ -0,0 +1,23 @@
import ElementPlus from 'element-plus'
import piniaPersistState from 'pinia-plugin-persistedstate'
import 'normalize.css'
import '@/assets/scss/element.scss'
import '@/assets/scss/common.scss'
import '@/assets/scss/style.scss'
import permission from '@/directive/permission'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia().use(piniaPersistState))
app.use(router)
app.use(ElementPlus)
app.use(permission)
app.mount('#app')

View File

@ -0,0 +1,36 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import LayoutView from '@/layouts/default/Layout.vue'
import { getMenus, buildRoutes } from '@/assets/js/menu'
const components = import.meta.glob("../views/**/**.vue")
const childRoutes = buildRoutes(getMenus(), components);
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/',
name: 'App',
component: LayoutView,
children: childRoutes
},
{
path: '/login',
name: 'Login',
meta: { allowGuest: true },
component: () => import('../views/account/Login.vue')
},
{
path: '/redirect/:path*',
component: () => import('../views/redirect/Redirect.vue'),
}
]
})
export default router

View File

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
const useSystemStore = defineStore('system', {
state: () => {
return {
loading: false,
collapse: false,
sidebarWidth: 200,
flowSidebarVisible: false,
lang: 'zh-CN',
logoUrl: '//s3.tebi.io/tenos/images/logo/jc.png',
copyright: '2005-2023 JUSTCALL 版权 © 2023 集时股份呼叫中心开发团队',
productName: '呼叫中心平台',
variables: {},
}
},
getters: {
},
actions: {
activeLoading() {
this.loading = true
},
inactiveLoading() {
this.loading = false
},
setLanguage(lang) {
this.lang = lang
},
toggleFlowSidebarVisible() {
this.flowSidebarVisible = !this.flowSidebarVisible;
if (this.flowSidebarVisible) {
this.collapse = false;
} else {
this.sidebarWidth = 200;
}
},
closeFlowSidebar() {
if (this.flowSidebarVisible) {
this.toggleFlowSidebarVisible();
}
},
toggleCollapse() {
this.collapse = !this.collapse;
},
setAttributeValue(attr, value) {
switch (attr) {
case 'name':
this.productName = value;
break
case 'logo':
this.logoUrl = value;
break;
case 'copyright':
this.copyright = value;
break;
default:
this.variables[attr] = value
}
}
},
persist: {
key: 'G_PTST4',
paths: ['lang', 'collapse', 'sidebarWidth']
},
})
export default useSystemStore

View File

@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
const useThemeStore = defineStore('theme', {
state: () => {
return {
name: 'light',
backgroundColor: '#F9FBFD',
headerBackgroundColor: '#FFFFFF',
sidebarBackgroundColor: '#152e4d',
sidebarTextColor: '#a8b5c8',
sidebarActiveTextColor: '#ffffff',
}
},
getters: {},
persist: true,
})
export default useThemeStore

View File

@ -0,0 +1,123 @@
import { defineStore } from 'pinia'
import { getBaseUrl } from '@/apis/request'
const useUserStore = defineStore('user', {
state: () => {
return {
uid: '',
username: '',
email: '',
avatar: '//rd.echo.me/fake/users/user-1.jpg',
pwdhash: '',
accessToken: '*',
tokenExpiredAt: 0,
permissions: [],
description: '',
loginDevice: false,
hiddlePhone: false, //号码是否隐藏
deviceRegistered: false,
unreadMsgCount: 0,
}
},
getters: {},
actions: {
setUserID(uid) {
this.uid = uid
},
setAccessToken(token, ttl) {
this.accessToken = token
if (ttl <= 0) {
this.tokenExpiredAt = 0;
sessionStorage.removeItem('G_PSID1');
} else {
this.tokenExpiredAt = ((new Date()).getTime() / 1000) + ttl;
sessionStorage.setItem('G_PSID1', this.accessToken);
}
},
setUsername(s) {
this.username = s
},
setEmail(s) {
this.email = s
},
setPassword(s) {
this.pwdhash = btoa(s)
},
setAvatar(s) {
if (s.indexOf('//') === -1) {
s = getBaseUrl() + s;
}
this.avatar = s
},
setAutoLoginDevice(s) {
this.loginDevice = s;
},
setDeviceRegisterState(ok) {
this.deviceRegistered = ok
},
setPermissions(s) {
if (typeof s === 'string') {
try {
this.permissions = JSON.parse(s)
} catch (e) { }
} else {
this.permissions = s
}
},
isAuthorization() {
let timestamp = (new Date()).getTime() / 1000;
return this.tokenExpiredAt > timestamp;
},
incUnreadMsgCount() {
this.unreadMsgCount++
},
decUnreadMsgCount() {
this.unreadMsgCount--
},
setUnreadMsgCount(n) {
this.unreadMsgCount = n
},
getUserID() {
return this.uid;
},
getAccessToken() {
return this.accessToken;
},
getAutoLoginDevice() {
return this.loginDevice
},
getPassword() {
return atob(this.pwdhash);
},
getUnreadMsgCount() {
return this.unreadMsgCount
},
getDeviceRegistered() {
return this.deviceRegistered;
},
hasPermission(permission) {
let ret = false
if (typeof permission === 'boolean') {
ret = permission
} else if (Array.isArray(permission)) {
for (let s of permission) {
if (this.permissions.indexOf(s) > -1) {
ret = true
break
}
}
} else {
ret = this.permissions.indexOf(permission) > -1
}
return ret
},
},
persist: {
key: 'G_PTST3',
paths: ['accessToken', 'tokenExpiredAt', 'pwdhash', 'loginDevice'],
storage: sessionStorage,
},
})
export default useUserStore

View File

@ -0,0 +1,219 @@
<template>
<div class="login">
<div class="login-container">
<div class="text-center login-header">
<h2>登录系统</h2>
</div>
<el-form label-position="top" size="large">
<el-form-item label="用户名">
<el-input v-model="model.username" placeholder="请输入用户名" :prefix-icon="User">
</el-input>
</el-form-item>
<el-form-item label="用户密码" :error="errorMessage">
<el-input v-model="model.password" placeholder="请输入用户密码" show-password :prefix-icon="Lock"
@keyup="handleKeyUp">
</el-input>
</el-form-item>
<el-form-item>
<el-checkbox v-model="model.remember"></el-checkbox>
</el-form-item>
<el-form-item>
<Vcode :show="verifyVisible" :sliderSize="18" :puzzleScale="0.6" className="v-puzzle-code"
sliderText="" @success="onVerifySuccess" />
<el-button type="primary" :loading="loading" :disable="loading" @click="handleLogin"></el-button>
</el-form-item>
</el-form>
<el-divider>
<span class="text-muted"></span>
</el-divider>
<div class="user-agreement">
<el-checkbox v-model="agreement">
<p class="text-muted text-small">同意 <a>用户协议</a> <a>使用协议</a></p>
</el-checkbox>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { userLogin, getUserProfile } from '@/apis/organize'
import useUserStore from '@/stores/user'
import { useRouter } from 'vue-router';
import { User, Lock } from '@element-plus/icons-vue';
import Vcode from "vue3-puzzle-vcode";
import { ElMessage } from 'element-plus';
const router = useRouter()
const model = ref({ username: '', password: '', remember: true })
const loading = ref(false)
const errorMessage = ref('')
const agreement = ref(true)
const verifyVisible = ref(false)
const submit = () => {
errorMessage.value = '';
if (!agreement.value) {
ElMessage.error("请先同意用户协议和使用协议");
return;
}
loading.value = true;
userLogin(model.value.username, model.value.password).then(res => {
const { setUserID, setAccessToken, setPassword, setUsername, setPermissions, setAvatar, setAutoLoginDevice } = useUserStore()
setUserID(res.uid);
setAccessToken(res.token, res.expire_in);
setPassword(model.value.password);
setAutoLoginDevice(model.value.remember);
getUserProfile().then(res => {
setUsername(res.username)
setAvatar(res.avatar)
setPermissions(res.permissions)
loading.value = false
router.push('/')
}).catch(e => {
loading.value = false
})
}).catch(e => {
loading.value = false
errorMessage.value = e.message
})
}
const onVerifySuccess = (e) => {
if (verifyVisible.value) {
verifyVisible.value = false;
submit();
}
}
const handleKeyUp = (e) => {
if (e.keyCode === 13) {
handleLogin()
}
}
const handleLogin = () => {
let isDev = process.env.NODE_ENV !== 'production';
if (!isDev) {
verifyVisible.value = true;
} else {
submit();
}
}
onMounted(() => {
if (process.env.NODE_ENV !== 'production') {
model.value = { username: '1000', password: 'Passwd#1000', remember: true };
}
})
</script>
<style lang="scss">
.login {
height: 100%;
background-image: linear-gradient(160deg, #f5f5f9 0%, #e8ebf0 100%);
position: relative;
box-sizing: border-box;
padding-top: 10vh;
.login-container {
width: 408px;
max-width: 96%;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
border-radius: 6px;
background-color: white;
padding: 1.5rem 2rem;
box-shadow: 0px 0px 30px 8px rgba(0, 0, 0, 0.046);
}
.el-icon {
font-size: 1.1rem;
margin-right: .9rem;
}
.login-header {
margin-bottom: 2rem;
text-align: center;
h2 {
letter-spacing: .28rem;
color: rgba(50, 71, 92, 0.87);
}
}
.user-agreement {
a {
color: var(--el-color-primary) !important;
}
}
.text-small {
font-size: .68rem;
}
.el-input {
--el-input-border-radius: .375rem;
--el-text-color-regular: #12263F;
}
.el-button {
width: 100%;
--el-border-radius-base: .375rem;
}
.container {
width: 100%;
margin-left: auto;
margin-right: auto;
}
@media(min-width: 576px) {
.container,
.container-sm {
max-width: 540px
}
}
@media(min-width: 768px) {
.container,
.container-md,
.container-sm {
max-width: 720px
}
}
@media(min-width: 992px) {
.container,
.container-lg,
.container-md,
.container-sm {
max-width: 960px
}
}
@media(min-width: 1200px) {
.container,
.container-lg,
.container-md,
.container-sm,
.container-xl {
max-width: 1140px
}
}
}
</style>

View File

@ -0,0 +1,8 @@
<template>
</template>
<script setup>
</script>

View File

@ -0,0 +1,24 @@
<template>
<viewer :title="title" :module-name="moduleName" :table-name="tableName" :disable-toolbar="false"
defaultSortable="id"></viewer>
</template>
<script setup>
import Viewer from '@/components/fragment/Viewer.vue';
import { computed } from 'vue';
const props = defineProps({
title: {
type: String,
}
})
const moduleName = computed(() => {
return 'moto'
})
const tableName = computed(() => {
return 'departments'
})
</script>

View File

@ -0,0 +1,7 @@
<template>
</template>
<script setup>
</script>

18
web/vite.config.js 100644
View File

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})