Initial commit

This commit is contained in:
tiny-craft 2023-06-27 15:53:29 +08:00
parent 219f2582bf
commit f8882d4eea
118 changed files with 14251 additions and 2 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
build/bin
node_modules
frontend/dist
.vscode
.idea

View File

@ -1,2 +1,10 @@
# tiny-rdm # README
Redis Desktop Manager
## Tiny RDM
## About
This is the official Wails Vue template.
You can configure the project by editing `wails.json`. More information about the project settings can be found
here: https://wails.io/docs/reference/project-config

27
app.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"context"
"fmt"
)
// App struct
type App struct {
ctx context.Context
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}

View File

@ -0,0 +1,861 @@
package services
import (
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"log"
"strconv"
"strings"
"sync"
"time"
. "tinyrdm/backend/storage"
"tinyrdm/backend/types"
maputil "tinyrdm/backend/utils/map"
redis2 "tinyrdm/backend/utils/redis"
)
type connectionService struct {
ctx context.Context
conns *ConnectionsStorage
connMap map[string]connectionItem
}
type connectionItem struct {
rdb *redis.Client
ctx context.Context
cancelFunc context.CancelFunc
}
type keyItem struct {
Type string `json:"t"`
}
var connection *connectionService
var onceConnection sync.Once
func Connection() *connectionService {
if connection == nil {
onceConnection.Do(func() {
connection = &connectionService{
conns: NewConnections(),
connMap: map[string]connectionItem{},
}
})
}
return connection
}
func (c *connectionService) Start(ctx context.Context) {
c.ctx = ctx
}
func (c *connectionService) Stop(ctx context.Context) {
for _, item := range c.connMap {
if item.rdb != nil {
item.cancelFunc()
item.rdb.Close()
}
}
c.connMap = map[string]connectionItem{}
}
func (c *connectionService) TestConnection(host string, port int, username, password string) (resp types.JSResp) {
rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", host, port),
Username: username,
Password: password,
})
defer rdb.Close()
if _, err := rdb.Ping(c.ctx).Result(); err != nil && err != redis.Nil {
resp.Msg = err.Error()
} else {
resp.Success = true
}
return
}
// ListConnection list all saved connection in local profile
func (c *connectionService) ListConnection() (resp types.JSResp) {
resp.Success = true
resp.Data = c.conns.GetConnections()
return
}
// SaveConnection save connection config to local profile
func (c *connectionService) SaveConnection(param types.Connection, replace bool) (resp types.JSResp) {
if err := c.conns.UpsertConnection(param, replace); err != nil {
resp.Msg = err.Error()
} else {
resp.Success = true
}
return
}
// OpenConnection open redis server connection
func (c *connectionService) OpenConnection(name string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(name, 0)
if err != nil {
resp.Msg = err.Error()
return
}
// get total database
config, err := rdb.ConfigGet(ctx, "databases").Result()
if err != nil {
resp.Msg = err.Error()
return
}
totaldb, err := strconv.Atoi(config["database"])
if err != nil {
totaldb = 16
}
// get database info
res, err := rdb.Info(ctx, "keyspace").Result()
if err != nil {
resp.Msg = "list database fail:" + err.Error()
return
}
// Parse all db, response content like below
var dbs []types.ConnectionDB
info := c.parseInfo(res)
for i := 0; i < totaldb; i++ {
dbName := "db" + strconv.Itoa(i)
dbInfoStr := info[dbName]
if len(dbInfoStr) > 0 {
dbInfo := c.parseDBItemInfo(dbInfoStr)
dbs = append(dbs, types.ConnectionDB{
Name: dbName,
Keys: dbInfo["keys"],
Expires: dbInfo["expires"],
AvgTTL: dbInfo["avg_ttl"],
})
} else {
dbs = append(dbs, types.ConnectionDB{
Name: dbName,
})
}
}
resp.Success = true
resp.Data = map[string]any{
"db": dbs,
}
return
}
// CloseConnection close redis server connection
func (c *connectionService) CloseConnection(name string) (resp types.JSResp) {
item, ok := c.connMap[name]
if ok {
delete(c.connMap, name)
if item.rdb != nil {
item.cancelFunc()
item.rdb.Close()
}
}
resp.Success = true
return
}
// get redis client from local cache or create a new open
// if db >= 0, also switch to db index
func (c *connectionService) getRedisClient(connName string, db int) (*redis.Client, context.Context, error) {
item, ok := c.connMap[connName]
var rdb *redis.Client
var ctx context.Context
if ok {
rdb, ctx = item.rdb, item.ctx
} else {
connGroups := c.conns.GetConnections()
var selConn *types.Connection
for _, connGroup := range connGroups {
for _, conn := range connGroup.Connections {
if conn.Name == connName {
selConn = &conn
break
}
}
}
if selConn == nil {
return nil, nil, errors.New("no match connection connName")
}
rdb = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", selConn.Addr, selConn.Port),
Username: selConn.Username,
Password: selConn.Password,
ReadTimeout: -1,
})
rdb.AddHook(redis2.NewHook(connName))
if _, err := rdb.Ping(c.ctx).Result(); err != nil && err != redis.Nil {
return nil, nil, errors.New("can not connect to redis server:" + err.Error())
}
var cancelFunc context.CancelFunc
ctx, cancelFunc = context.WithCancel(c.ctx)
c.connMap[connName] = connectionItem{
rdb: rdb,
ctx: ctx,
cancelFunc: cancelFunc,
}
}
if db >= 0 {
if err := rdb.Do(ctx, "SELECT", strconv.Itoa(db)).Err(); err != nil {
return nil, nil, err
}
}
return rdb, ctx, nil
}
// parse command response content which use "redis info"
// # Keyspace\r\ndb0:keys=2,expires=1,avg_ttl=1877111749\r\ndb1:keys=33,expires=0,avg_ttl=0\r\ndb3:keys=17,expires=0,avg_ttl=0\r\ndb5:keys=3,expires=0,avg_ttl=0\r\n
func (c *connectionService) parseInfo(info string) map[string]string {
parsedInfo := map[string]string{}
lines := strings.Split(info, "\r\n")
if len(lines) > 0 {
for _, line := range lines {
if !strings.HasPrefix(line, "#") {
items := strings.SplitN(line, ":", 2)
if len(items) < 2 {
continue
}
parsedInfo[items[0]] = items[1]
}
}
}
return parsedInfo
}
// parse db item value, content format like below
// keys=2,expires=1,avg_ttl=1877111749
func (c *connectionService) parseDBItemInfo(info string) map[string]int {
ret := map[string]int{}
items := strings.Split(info, ",")
for _, item := range items {
kv := strings.SplitN(item, "=", 2)
if len(kv) > 1 {
ret[kv[0]], _ = strconv.Atoi(kv[1])
}
}
return ret
}
// OpenDatabase open select database, and list all keys
// @param path contain connection name and db name
func (c *connectionService) OpenDatabase(connName string, db int) (resp types.JSResp) {
log.Println("open db:" + strconv.Itoa(db))
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
//var keys []string
keys := map[string]keyItem{}
var cursor uint64
for {
var loadedKey []string
loadedKey, cursor, err = rdb.Scan(ctx, cursor, "*", 10000).Result()
if err != nil {
resp.Msg = err.Error()
return
}
//c.updateDBKey(connName, db, loadedKey)
for _, k := range loadedKey {
//t, _ := rdb.Type(ctx, k).Result()
keys[k] = keyItem{Type: "t"}
}
//keys = append(keys, loadedKey...)
// no more loadedKey
if cursor == 0 {
break
}
}
resp.Success = true
resp.Data = map[string]any{
"keys": keys,
}
return
}
// GetKeyValue get value by key
func (c *connectionService) GetKeyValue(connName string, db int, key string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
var keyType string
var dur time.Duration
keyType, err = rdb.Type(ctx, key).Result()
if err != nil {
resp.Msg = err.Error()
return
}
var ttl int64
if dur, err = rdb.TTL(ctx, key).Result(); err != nil {
ttl = -1
} else {
if dur < 0 {
ttl = -1
} else {
ttl = int64(dur.Seconds())
}
}
var value any
var cursor uint64
switch strings.ToLower(keyType) {
case "string":
value, err = rdb.Get(ctx, key).Result()
case "list":
value, err = rdb.LRange(ctx, key, 0, -1).Result()
case "hash":
//value, err = rdb.HGetAll(ctx, key).Result()
items := map[string]string{}
for {
var loadedVal []string
loadedVal, cursor, err = rdb.HScan(ctx, key, cursor, "*", 10000).Result()
if err != nil {
resp.Msg = err.Error()
return
}
for i := 0; i < len(loadedVal); i += 2 {
items[loadedVal[i]] = loadedVal[i+1]
}
if cursor == 0 {
break
}
}
value = items
case "set":
//value, err = rdb.SMembers(ctx, key).Result()
items := []string{}
for {
var loadedKey []string
loadedKey, cursor, err = rdb.SScan(ctx, key, cursor, "*", 10000).Result()
if err != nil {
resp.Msg = err.Error()
return
}
items = append(items, loadedKey...)
if cursor == 0 {
break
}
}
value = items
case "zset":
//value, err = rdb.ZRangeWithScores(ctx, key, 0, -1).Result()
var items []types.ZSetItem
for {
var loadedVal []string
loadedVal, cursor, err = rdb.ZScan(ctx, key, cursor, "*", 10000).Result()
if err != nil {
resp.Msg = err.Error()
return
}
var score float64
for i := 0; i < len(loadedVal); i += 2 {
if score, err = strconv.ParseFloat(loadedVal[i+1], 64); err == nil {
items = append(items, types.ZSetItem{
Value: loadedVal[i],
Score: score,
})
}
}
if cursor == 0 {
break
}
}
value = items
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"type": keyType,
"ttl": ttl,
"value": value,
}
return
}
// SetKeyValue set value by key
func (c *connectionService) SetKeyValue(connName string, db int, key, keyType string, value any, ttl int64) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
var expiration time.Duration
if ttl < 0 {
expiration = redis.KeepTTL
} else {
expiration = time.Duration(ttl) * time.Second
}
switch strings.ToLower(keyType) {
case "string":
if str, ok := value.(string); !ok {
resp.Msg = "invalid string value"
return
} else {
_, err = rdb.Set(ctx, key, str, expiration).Result()
}
case "list":
if strs, ok := value.([]any); !ok {
resp.Msg = "invalid list value"
return
} else {
_, err = rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.LPush(ctx, key, strs...)
if expiration > 0 {
pipe.Expire(ctx, key, expiration)
}
return nil
})
}
case "hash":
if strs, ok := value.([]any); !ok {
resp.Msg = "invalid hash value"
return
} else {
_, err = rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
if len(strs) > 1 {
for i := 0; i < len(strs); i += 2 {
pipe.HSetNX(ctx, key, strs[i].(string), strs[i+1])
}
} else {
pipe.HSet(ctx, key)
}
if expiration > 0 {
pipe.Expire(ctx, key, expiration)
}
return nil
})
}
case "set":
if strs, ok := value.([]any); !ok || len(strs) <= 0 {
resp.Msg = "invalid set value"
return
} else {
_, err = rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
if len(strs) > 1 {
for _, str := range strs {
pipe.SAdd(ctx, key, str.(string))
}
} else {
pipe.SAdd(ctx, key)
}
if expiration > 0 {
pipe.Expire(ctx, key, expiration)
}
return nil
})
}
case "zset":
if strs, ok := value.([]any); !ok || len(strs) <= 0 {
resp.Msg = "invalid zset value"
return
} else {
_, err = rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
var members []redis.Z
for i := 0; i < len(strs); i += 2 {
members = append(members, redis.Z{
Score: strs[i].(float64),
Member: strs[i+1],
})
}
if len(members) > 0 {
pipe.ZAdd(ctx, key, members...)
} else {
pipe.ZAdd(ctx, key)
}
if expiration > 0 {
pipe.Expire(ctx, key, expiration)
}
return nil
})
}
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"value": value,
}
return
}
// SetHashValue set hash field
func (c *connectionService) SetHashValue(connName string, db int, key, field, newField, value string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
var removedField []string
updatedField := map[string]string{}
if len(field) <= 0 {
// old filed is empty, add new field
_, err = rdb.HSet(ctx, key, newField, value).Result()
updatedField[newField] = value
} else if len(newField) <= 0 {
// new field is empty, delete old field
_, err = rdb.HDel(ctx, key, field, value).Result()
removedField = append(removedField, field)
} else if field == newField {
// replace field
_, err = rdb.HSet(ctx, key, newField, value).Result()
updatedField[newField] = value
} else {
// remove old field and add new field
if _, err = rdb.HDel(ctx, key, field).Result(); err != nil {
resp.Msg = err.Error()
return
}
_, err = rdb.HSet(ctx, key, newField, value).Result()
removedField = append(removedField, field)
updatedField[newField] = value
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"removed": removedField,
"updated": updatedField,
}
return
}
// AddHashField add or update hash field
func (c *connectionService) AddHashField(connName string, db int, key string, action int, fieldItems []any) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
updated := map[string]any{}
switch action {
case 1:
// ignore duplicated fields
for i := 0; i < len(fieldItems); i += 2 {
_, err = rdb.HSetNX(ctx, key, fieldItems[i].(string), fieldItems[i+1]).Result()
if err == nil {
updated[fieldItems[i].(string)] = fieldItems[i+1]
}
}
default:
// overwrite duplicated fields
_, err = rdb.HSet(ctx, key, fieldItems...).Result()
for i := 0; i < len(fieldItems); i += 2 {
updated[fieldItems[i].(string)] = fieldItems[i+1]
}
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"updated": updated,
}
return
}
// AddListItem add item to list or remove from it
func (c *connectionService) AddListItem(connName string, db int, key string, action int, items []any) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
var leftPush, rightPush []any
switch action {
case 0:
// push to head
_, err = rdb.LPush(ctx, key, items...).Result()
leftPush = append(leftPush, items...)
default:
// append to tail
_, err = rdb.RPush(ctx, key, items...).Result()
rightPush = append(rightPush, items...)
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"left": leftPush,
"right": rightPush,
}
return
}
// SetListItem update or remove list item by index
func (c *connectionService) SetListItem(connName string, db int, key string, index int64, value string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
var removed []int64
updated := map[int64]string{}
if len(value) <= 0 {
// remove from list
err = rdb.LSet(ctx, key, index, "---VALUE_REMOVED_BY_TINY_RDM---").Err()
if err != nil {
resp.Msg = err.Error()
return
}
err = rdb.LRem(ctx, key, 1, "---VALUE_REMOVED_BY_TINY_RDM---").Err()
if err != nil {
resp.Msg = err.Error()
return
}
removed = append(removed, index)
} else {
// replace index value
err = rdb.LSet(ctx, key, index, value).Err()
if err != nil {
resp.Msg = err.Error()
return
}
updated[index] = value
}
resp.Success = true
resp.Data = map[string]any{
"removed": removed,
"updated": updated,
}
return
}
// SetSetItem add members to set or remove from set
func (c *connectionService) SetSetItem(connName string, db int, key string, remove bool, members []any) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
if remove {
_, err = rdb.SRem(ctx, key, members...).Result()
} else {
_, err = rdb.SAdd(ctx, key, members...).Result()
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
return
}
// UpdateSetItem replace member of set
func (c *connectionService) UpdateSetItem(connName string, db int, key, value, newValue string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
_, _ = rdb.SRem(ctx, key, value).Result()
_, err = rdb.SAdd(ctx, key, newValue).Result()
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
return
}
// UpdateZSetValue update value of sorted set member
func (c *connectionService) UpdateZSetValue(connName string, db int, key, value, newValue string, score float64) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
updated := map[string]any{}
var removed []string
if len(newValue) <= 0 {
// blank new value, delete value
_, err = rdb.ZRem(ctx, key, value).Result()
if err == nil {
removed = append(removed, value)
}
} else if newValue == value {
// update score only
_, err = rdb.ZAdd(ctx, key, redis.Z{
Score: score,
Member: value,
}).Result()
} else {
// remove old value and add new one
_, err = rdb.ZRem(ctx, key, value).Result()
if err == nil {
removed = append(removed, value)
}
_, err = rdb.ZAdd(ctx, key, redis.Z{
Score: score,
Member: newValue,
}).Result()
if err == nil {
updated[newValue] = score
}
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"updated": updated,
"removed": removed,
}
return
}
// AddZSetValue add item to sorted set
func (c *connectionService) AddZSetValue(connName string, db int, key string, action int, valueScore map[string]float64) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
members := maputil.ToSlice(valueScore, func(k string) redis.Z {
return redis.Z{
Score: valueScore[k],
Member: k,
}
})
switch action {
case 1:
// ignore duplicated fields
_, err = rdb.ZAddNX(ctx, key, members...).Result()
default:
// overwrite duplicated fields
_, err = rdb.ZAdd(ctx, key, members...).Result()
}
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
return
}
// SetKeyTTL set ttl of key
func (c *connectionService) SetKeyTTL(connName string, db int, key string, ttl int64) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
var expiration time.Duration
if ttl < 0 {
if err = rdb.Persist(ctx, key).Err(); err != nil {
resp.Msg = err.Error()
return
}
} else {
expiration = time.Duration(ttl) * time.Second
if err = rdb.Expire(ctx, key, expiration).Err(); err != nil {
resp.Msg = err.Error()
return
}
}
resp.Success = true
return
}
// RemoveKey remove redis key
func (c *connectionService) RemoveKey(connName string, db int, key string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
rmCount, err := rdb.Del(ctx, key).Result()
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
resp.Data = map[string]any{
"effect_count": rmCount,
}
return
}
// RenameKey rename key
func (c *connectionService) RenameKey(connName string, db int, key, newKey string) (resp types.JSResp) {
rdb, ctx, err := c.getRedisClient(connName, db)
if err != nil {
resp.Msg = err.Error()
return
}
_, err = rdb.RenameNX(ctx, key, newKey).Result()
if err != nil {
resp.Msg = err.Error()
return
}
resp.Success = true
return
}
// update or insert key info to database
//func (c *connectionService) updateDBKey(connName string, db int, keys []string, separator string) {
// dbStruct := map[string]any{}
// for _, key := range keys {
// keyPart := strings.Split(key, separator)
// prefixLen := len(keyPart)-1
// if prefixLen > 0 {
// for i := 0; i < prefixLen; i++ {
// if dbStruct[keyPart[i]]
// keyPart[i]
// }
// }
// log.Println("key", key)
// }
//}

View File

@ -0,0 +1,18 @@
package services
import "sync"
type storageService struct {
}
var storage *storageService
var onceStorage sync.Once
func Storage() *storageService {
if storage == nil {
onceStorage.Do(func() {
storage = &storageService{}
})
}
return storage
}

View File

@ -0,0 +1,215 @@
package storage
import (
"errors"
"gopkg.in/yaml.v3"
"sync"
"tinyrdm/backend/types"
sliceutil "tinyrdm/backend/utils/slice"
)
type ConnectionsStorage struct {
storage *localStorage
mutex sync.Mutex
}
func NewConnections() *ConnectionsStorage {
return &ConnectionsStorage{
storage: NewLocalStore("connections.yaml"),
}
}
func (c *ConnectionsStorage) defaultConnections() []types.ConnectionGroup {
return []types.ConnectionGroup{
{
GroupName: "",
Connections: []types.Connection{},
},
}
}
func (c *ConnectionsStorage) defaultConnectionItem() types.Connection {
return types.Connection{
Name: "",
Addr: "127.0.0.1",
Port: 6379,
Username: "",
Password: "",
DefaultFilter: "*",
KeySeparator: ":",
ConnTimeout: 60,
ExecTimeout: 60,
MarkColor: "",
}
}
func (c *ConnectionsStorage) getConnections() (ret []types.ConnectionGroup) {
b, err := c.storage.Load()
if err != nil {
ret = c.defaultConnections()
return
}
if err = yaml.Unmarshal(b, &ret); err != nil {
ret = c.defaultConnections()
return
}
if len(ret) <= 0 {
ret = c.defaultConnections()
}
//if !sliceutil.AnyMatch(ret, func(i int) bool {
// return ret[i].GroupName == ""
//}) {
// ret = append(ret, c.defaultConnections()...)
//}
return
}
// GetConnections get all store connections from local
func (c *ConnectionsStorage) GetConnections() (ret []types.ConnectionGroup) {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.getConnections()
}
// GetConnectionsFlat get all store connections from local flat(exclude group level)
func (c *ConnectionsStorage) GetConnectionsFlat() (ret []types.Connection) {
c.mutex.Lock()
defer c.mutex.Unlock()
conns := c.getConnections()
for _, group := range conns {
for _, conn := range group.Connections {
ret = append(ret, conn)
}
}
return
}
func (c *ConnectionsStorage) saveConnections(conns []types.ConnectionGroup) error {
b, err := yaml.Marshal(&conns)
if err != nil {
return err
}
if err = c.storage.Store(b); err != nil {
return err
}
return nil
}
// UpsertConnection update or insert a connection
func (c *ConnectionsStorage) UpsertConnection(param types.Connection, replace bool) error {
c.mutex.Lock()
defer c.mutex.Unlock()
conns := c.getConnections()
groupIndex := -1
connIndex := -1
for i, group := range conns {
for j, conn := range group.Connections {
// check conflict connection name
if conn.Name == param.Name {
if !replace {
return errors.New("duplicated connection name")
} else {
// different group name, should move group
// remove from current group first
if group.GroupName != param.Group {
group.Connections = append(group.Connections[:j], group.Connections[j+1:]...)
// find new group index
groupIndex, _ = sliceutil.Find(conns, func(i int) bool {
return conns[i].GroupName == param.Group
})
} else {
groupIndex = i
connIndex = j
}
break
}
}
}
}
if groupIndex >= 0 {
// group exists
if connIndex >= 0 {
// connection exists
conns[groupIndex].Connections[connIndex] = param
} else {
// new connection
conns[groupIndex].Connections = append(conns[groupIndex].Connections, param)
}
} else {
// new group
group := types.ConnectionGroup{
GroupName: param.Group,
Connections: []types.Connection{param},
}
conns = append(conns, group)
}
return c.saveConnections(conns)
}
// RemoveConnection remove special connection
func (c *ConnectionsStorage) RemoveConnection(group, name string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
conns := c.getConnections()
for i, connGroup := range conns {
if connGroup.GroupName == group {
for j, conn := range connGroup.Connections {
if conn.Name == name {
connList := conns[i].Connections
connList = append(connList[:j], connList[j+1:]...)
conns[i].Connections = connList
return c.saveConnections(conns)
}
}
}
}
return errors.New("no match connection")
}
// UpsertGroup update or insert a group
// When want to create group only, set group == param.name
func (c *ConnectionsStorage) UpsertGroup(group string, param types.ConnectionGroup) error {
c.mutex.Lock()
defer c.mutex.Unlock()
conns := c.getConnections()
for i, connGroup := range conns {
if connGroup.GroupName == group {
conns[i].GroupName = param.GroupName
return c.saveConnections(conns)
}
}
// No match group, create one
connGroup := types.ConnectionGroup{
GroupName: param.GroupName,
Connections: []types.Connection{},
}
conns = append(conns, connGroup)
return c.saveConnections(conns)
}
// RemoveGroup remove special group, include all connections under it
func (c *ConnectionsStorage) RemoveGroup(group string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
conns := c.getConnections()
for i, connGroup := range conns {
if connGroup.GroupName == group {
conns = append(conns[:i], conns[i+1:]...)
return c.saveConnections(conns)
}
}
return errors.New("no match group")
}

View File

@ -0,0 +1,55 @@
package storage
import (
"github.com/vrischmann/userdir"
"os"
"path"
)
// localStorage provides reading and writing application data to the user's
// configuration directory.
type localStorage struct {
ConfPath string
}
// NewLocalStore returns a localStore instance.
func NewLocalStore(filename string) *localStorage {
return &localStorage{
ConfPath: path.Join(userdir.GetConfigHome(), "TinyRDM", filename),
}
}
// Load reads the given file in the user's configuration directory and returns
// its contents.
func (l *localStorage) Load() ([]byte, error) {
d, err := os.ReadFile(l.ConfPath)
if err != nil {
return nil, err
}
return d, err
}
// Store writes data to the user's configuration directory at the given
// filename.
func (l *localStorage) Store(data []byte) error {
dir := path.Dir(l.ConfPath)
if err := ensureDirExists(dir); err != nil {
return err
}
if err := os.WriteFile(l.ConfPath, data, 0777); err != nil {
return err
}
return nil
}
// ensureDirExists checks for the existence of the directory at the given path,
// which is created if it does not exist.
func ensureDirExists(path string) error {
_, err := os.Stat(path)
if os.IsNotExist(err) {
if err = os.Mkdir(path, 0777); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,124 @@
package storage
import (
"fmt"
"gopkg.in/yaml.v3"
"strings"
"sync"
)
type PreferencesStorage struct {
storage *localStorage
mutex sync.Mutex
}
func NewPreferences() *PreferencesStorage {
return &PreferencesStorage{
storage: NewLocalStore("preferences.yaml"),
}
}
func (p *PreferencesStorage) DefaultPreferences() map[string]any {
return map[string]any{
"general": map[string]any{
"language": "en",
"font": "",
"font_size": 14,
"use_proxy": false,
"use_proxy_http": false,
"check_update": true,
},
"editor": map[string]any{
"font": "",
"font_size": 14,
},
}
}
func (p *PreferencesStorage) getPreferences() (ret map[string]any) {
b, err := p.storage.Load()
if err != nil {
ret = p.DefaultPreferences()
return
}
if err := yaml.Unmarshal(b, &ret); err != nil {
ret = p.DefaultPreferences()
return
}
return
}
// GetPreferences Get preferences from local
func (p *PreferencesStorage) GetPreferences() (ret map[string]any) {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.getPreferences()
}
func (p *PreferencesStorage) setPreferences(pf map[string]any, key string, value any) error {
keyPath := strings.Split(key, ".")
if len(keyPath) <= 0 {
return fmt.Errorf("invalid key path(%s)", key)
}
var node any = pf
for _, k := range keyPath[:len(keyPath)-1] {
if subNode, ok := node.(map[string]any); ok {
node = subNode[k]
} else {
return fmt.Errorf("invalid key path(%s)", key)
}
}
if subNode, ok := node.(map[string]any); ok {
subNode[keyPath[len(keyPath)-1]] = value
}
return nil
}
func (p *PreferencesStorage) savePreferences(pf map[string]any) error {
b, err := yaml.Marshal(&pf)
if err != nil {
return err
}
if err = p.storage.Store(b); err != nil {
return err
}
return nil
}
// SetPreferences assign value to key path, the key path use "." to indicate multiple level
func (p *PreferencesStorage) SetPreferences(key string, value any) error {
p.mutex.Lock()
defer p.mutex.Unlock()
pf := p.getPreferences()
if err := p.setPreferences(pf, key, value); err != nil {
return err
}
return p.savePreferences(pf)
}
// SetPreferencesN set multiple key path and value
func (p *PreferencesStorage) SetPreferencesN(values map[string]any) error {
p.mutex.Lock()
defer p.mutex.Unlock()
pf := p.getPreferences()
for path, v := range values {
if err := p.setPreferences(pf, path, v); err != nil {
return err
}
}
return p.savePreferences(pf)
}
func (p *PreferencesStorage) RestoreDefault() map[string]any {
pf := p.DefaultPreferences()
p.savePreferences(pf)
return pf
}

View File

@ -0,0 +1,29 @@
package types
type ConnectionCategory int
type Connection struct {
Group string `json:"group" yaml:"-"`
Name string `json:"name" yaml:"name"`
Addr string `json:"addr" yaml:"addr"`
Port int `json:"port" yaml:"port"`
Username string `json:"username" yaml:"username"`
Password string `json:"password" yaml:"password"`
DefaultFilter string `json:"defaultFilter" yaml:"default_filter"`
KeySeparator string `json:"keySeparator" yaml:"key_separator"`
ConnTimeout int `json:"connTimeout" yaml:"conn_timeout"`
ExecTimeout int `json:"execTimeout" yaml:"exec_timeout"`
MarkColor string `json:"markColor" yaml:"mark_color"`
}
type ConnectionGroup struct {
GroupName string `json:"groupName" yaml:"group_name"`
Connections []Connection `json:"connections" yaml:"connections"`
}
type ConnectionDB struct {
Name string `json:"name"`
Keys int `json:"keys"`
Expires int `json:"expires,omitempty"`
AvgTTL int `json:"avgTtl,omitempty"`
}

7
backend/types/js_resp.go Normal file
View File

@ -0,0 +1,7 @@
package types
type JSResp struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Data any `json:"data,omitempty"`
}

6
backend/types/zset.go Normal file
View File

@ -0,0 +1,6 @@
package types
type ZSetItem struct {
Value string `json:"value"`
Score float64 `json:"score"`
}

View File

@ -0,0 +1,82 @@
package coll
// Queue 队列, 先进先出
type Queue[T any] []T
func NewQueue[T any](elems ...T) Queue[T] {
if len(elems) > 0 {
data := make([]T, len(elems))
copy(data, elems)
return data
} else {
return Queue[T]{}
}
}
// Push 尾部插入元素
func (q *Queue[T]) Push(elem T) {
if q == nil {
return
}
*q = append(*q, elem)
}
// PushN 尾部插入多个元素
func (q *Queue[T]) PushN(elems ...T) {
if q == nil {
return
}
if len(elems) <= 0 {
return
}
*q = append(*q, elems...)
}
// Pop 移除并返回头部元素
func (q *Queue[T]) Pop() (T, bool) {
var elem T
if q == nil || len(*q) <= 0 {
return elem, false
}
elem = (*q)[0]
*q = (*q)[1:]
return elem, true
}
func (q *Queue[T]) PopN(n int) []T {
if q == nil {
return []T{}
}
var popElems []T
if n <= 0 {
return []T{}
}
l := len(*q)
if n >= l {
popElems = *q
*q = []T{}
return *q
}
popElems = (*q)[:n]
*q = (*q)[n:]
return popElems
}
// Clear 移除所有元素
func (q *Queue[T]) Clear() {
if q == nil {
return
}
*q = []T{}
}
func (q Queue[T]) IsEmpty() bool {
return len(q) <= 0
}
func (q Queue[T]) Size() int {
return len(q)
}

287
backend/utils/coll/set.go Normal file
View File

@ -0,0 +1,287 @@
package coll
import (
"fmt"
json "github.com/bytedance/sonic"
"sort"
. "tinyrdm/backend/utils"
"tinyrdm/backend/utils/rand"
)
type Void struct{}
// Set 集合, 存放不重复的元素
type Set[T Hashable] map[T]Void
// type Set[T Hashable] struct {
// data map[T]Void
// }
func NewSet[T Hashable](elems ...T) Set[T] {
if len(elems) > 0 {
data := make(Set[T], len(elems))
for _, e := range elems {
data[e] = Void{}
}
return data
} else {
return Set[T]{}
}
}
// Add 添加元素
func (s Set[T]) Add(elem T) bool {
if s == nil {
return false
}
if _, exists := s[elem]; !exists {
s[elem] = Void{}
return true
}
return false
}
// AddN 添加多个元素
func (s Set[T]) AddN(elems ...T) int {
if s == nil {
return 0
}
addCount := 0
var exists bool
for _, elem := range elems {
if _, exists = s[elem]; !exists {
s[elem] = Void{}
addCount += 1
}
}
return addCount
}
// Merge 合并其他集合
func (s Set[T]) Merge(other Set[T]) int {
return s.AddN(other.ToSlice()...)
}
// Contains 判断是否存在指定元素
func (s Set[T]) Contains(elem T) bool {
if s == nil {
return false
}
_, exists := s[elem]
return exists
}
// ContainAny 判断是否包含任意元素
func (s Set[T]) ContainAny(elems ...T) bool {
if s == nil {
return false
}
var exists bool
for _, elem := range elems {
if _, exists = s[elem]; exists {
return true
}
}
return false
}
// Equals 判断两个集合内元素是否一致
func (s Set[T]) Equals(other Set[T]) bool {
if s.Size() != other.Size() {
return false
}
for elem := range s {
if !other.Contains(elem) {
return false
}
}
return true
}
// ContainAll 判断是否包含所有元素
func (s Set[T]) ContainAll(elems ...T) bool {
if s == nil {
return false
}
var exists bool
for _, elem := range elems {
if _, exists = s[elem]; !exists {
return false
}
}
return true
}
// Remove 移除元素
func (s Set[T]) Remove(elem T) bool {
if s == nil {
return false
}
if _, exists := s[elem]; exists {
delete(s, elem)
return true
}
return false
}
// RemoveN 移除多个元素
func (s Set[T]) RemoveN(elems ...T) int {
if s == nil {
return 0
}
var exists bool
removeCnt := 0
for _, elem := range elems {
if _, exists = s[elem]; exists {
delete(s, elem)
removeCnt += 1
}
}
return removeCnt
}
// RemoveSub 移除子集
func (s Set[T]) RemoveSub(subSet Set[T]) int {
if s == nil {
return 0
}
var exists bool
removeCnt := 0
for elem := range subSet {
if _, exists = s[elem]; exists {
delete(s, elem)
removeCnt += 1
}
}
return removeCnt
}
// Filter 根据条件筛出符合的元素
func (s Set[T]) Filter(filterFunc func(i T) bool) []T {
ret := []T{}
for v := range s {
if filterFunc(v) {
ret = append(ret, v)
}
}
return ret
}
// RandomElem 随机抽取一个元素
// @param remove 随机出来的元素是否同时从集合中移除
// @return 抽取的元素
// @return 是否抽取成功
func (s Set[T]) RandomElem(remove bool) (T, bool) {
size := s.Size()
if size > 0 {
selIdx := rand.Intn(size)
idx := 0
for elem := range s {
if idx == selIdx {
if remove {
delete(s, elem)
}
return elem, true
} else {
idx++
}
}
}
var r T
return r, false
}
// Size 集合长度
func (s Set[T]) Size() int {
return len(s)
}
// IsEmpty 判断是否为空
func (s Set[T]) IsEmpty() bool {
return len(s) <= 0
}
// Clear 清空集合
func (s Set[T]) Clear() {
for elem := range s {
delete(s, elem)
}
}
// ToSlice 转为切片
func (s Set[T]) ToSlice() []T {
size := len(s)
if size <= 0 {
return []T{}
}
ret := make([]T, 0, size)
for elem := range s {
ret = append(ret, elem)
}
return ret
}
// ToSortedSlice 转为排序好的切片
func (s Set[T]) ToSortedSlice(sortFunc func(v1, v2 T) bool) []T {
list := s.ToSlice()
sort.Slice(list, func(i, j int) bool {
return sortFunc(list[i], list[j])
})
return list
}
// Each 遍历检索每个元素
func (s Set[T]) Each(eachFunc func(T)) {
if len(s) <= 0 {
return
}
for elem := range s {
eachFunc(elem)
}
}
// Clone 克隆
func (s Set[T]) Clone() Set[T] {
if s == nil {
return nil
}
other := NewSet[T]()
for elem := range s {
other[elem] = Void{}
}
return other
}
func (s Set[T]) String() string {
arr := s.ToSlice()
return fmt.Sprintf("%v", arr)
}
// MarshalJSON to output non base64 encoded []byte
func (s Set[T]) MarshalJSON() ([]byte, error) {
if s == nil {
return []byte("null"), nil
}
t := s.ToSlice()
return json.Marshal(t)
}
// UnmarshalJSON to deserialize []byte
func (s *Set[T]) UnmarshalJSON(b []byte) error {
t := []T{}
err := json.Unmarshal(b, &t)
if err != nil {
*s = NewSet[T]()
} else {
*s = NewSet[T](t...)
}
return nil
}
// GormDataType gorm common data type
func (s Set[T]) GormDataType() string {
return "json"
}

View File

@ -0,0 +1,88 @@
package coll
// Stack 栈, 先进后出
type Stack[T any] []T
func NewStack[T any](elems ...T) Stack[T] {
if len(elems) > 0 {
data := make([]T, len(elems))
copy(data, elems)
return data
} else {
return Stack[T]{}
}
}
// Push 顶部添加一个元素
func (s *Stack[T]) Push(elem T) {
if s == nil {
panic("queue should not be nil")
}
*s = append(*s, elem)
}
// PushN 顶部添加一个元素
func (s *Stack[T]) PushN(elems ...T) {
if s == nil {
panic("queue should not be nil")
}
if len(elems) <= 0 {
return
}
*s = append(*s, elems...)
}
// Pop 移除并返回顶部元素
func (s *Stack[T]) Pop() T {
if s == nil {
panic("queue should not be nil")
}
l := len(*s)
popElem := (*s)[l-1]
*s = (*s)[:l-1]
return popElem
}
// PopN 移除并返回顶部多个元素
func (s *Stack[T]) PopN(n int) []T {
if s == nil {
panic("queue should not be nil")
}
var popElems []T
if n <= 0 {
return popElems
}
l := len(*s)
if n >= l {
popElems = *s
*s = []T{}
return *s
}
popElems = (*s)[l-n:]
*s = (*s)[:l-n]
// 翻转弹出结果
pl := len(popElems)
for i := 0; i < pl/2; i++ {
popElems[i], popElems[pl-i-1] = popElems[pl-i-1], popElems[i]
}
return popElems
}
// Clear 移除所有元素
func (s *Stack[T]) Clear() {
if s == nil {
panic("queue should not be nil")
}
*s = []T{}
}
func (s Stack[T]) IsEmpty() bool {
return len(s) <= 0
}
func (s Stack[T]) Size() int {
return len(s)
}

View File

@ -0,0 +1,13 @@
package utils
type Hashable interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~string
}
type SignedNumber interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64
}
type UnsignedNumber interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

View File

@ -0,0 +1,289 @@
package maputil
import (
. "tinyrdm/backend/utils"
"tinyrdm/backend/utils/coll"
)
// Get 获取键值对指定键的值, 如果不存在则返回自定默认值
func Get[M ~map[K]V, K Hashable, V any](m M, key K, defaultVal V) V {
if m != nil {
if v, exists := m[key]; exists {
return v
}
}
return defaultVal
}
// ContainsKey 判断指定键是否存在
func ContainsKey[M ~map[K]V, K Hashable, V any](m M, key K) bool {
if m == nil {
return false
}
_, exists := m[key]
return exists
}
// MustGet 获取键值对指定键的值, 如果不存在则调用给定的函数进行获取
func MustGet[M ~map[K]V, K Hashable, V any](m M, key K, getFunc func(K) V) V {
if v, exists := m[key]; exists {
return v
}
if getFunc != nil {
return getFunc(key)
}
var defaultV V
return defaultV
}
// Keys 获取键值对中所有键
func Keys[M ~map[K]V, K Hashable, V any](m M) []K {
if len(m) <= 0 {
return []K{}
}
keys := make([]K, len(m))
index := 0
for k := range m {
keys[index] = k
index += 1
}
return keys
}
// KeySet 获取键值对中所有键集合
func KeySet[M ~map[K]V, K Hashable, V any](m M) coll.Set[K] {
if len(m) <= 0 {
return coll.NewSet[K]()
}
keySet := coll.NewSet[K]()
for k := range m {
keySet.Add(k)
}
return keySet
}
// Values 获取键值对中所有值
func Values[M ~map[K]V, K Hashable, V any](m M) []V {
if len(m) <= 0 {
return []V{}
}
values := make([]V, len(m))
index := 0
for _, v := range m {
values[index] = v
index += 1
}
return values
}
// ValueSet 获取键值对中所有值集合
func ValueSet[M ~map[K]V, K Hashable, V Hashable](m M) coll.Set[V] {
if len(m) <= 0 {
return coll.NewSet[V]()
}
valueSet := coll.NewSet[V]()
for _, v := range m {
valueSet.Add(v)
}
return valueSet
}
// Fill 填充键值对
func Fill[M ~map[K]V, K Hashable, V any](dest M, src M) M {
for k, v := range src {
dest[k] = v
}
return dest
}
// Merge 合并键值对, 后续键值对有重复键的元素会覆盖旧元素
func Merge[M ~map[K]V, K Hashable, V any](mapArr ...M) M {
result := make(M, len(mapArr))
for _, m := range mapArr {
for k, v := range m {
result[k] = v
}
}
return result
}
// DeepMerge 深度递归覆盖src值到dst中
// 将返回新的值
func DeepMerge[M ~map[K]any, K Hashable](src1, src2 M) M {
out := make(map[K]any, len(src1))
for k, v := range src1 {
out[k] = v
}
for k, v := range src2 {
if v1, ok := v.(map[K]any); ok {
if bv, ok := out[k]; ok {
if bv1, ok := bv.(map[K]any); ok {
out[k] = DeepMerge(bv1, v1)
continue
}
}
}
out[k] = v
}
return out
}
// Omit 根据条件省略指定元素
func Omit[M ~map[K]V, K Hashable, V any](m M, omitFunc func(k K, v V) bool) (M, []K) {
result := M{}
var removedKeys []K
for k, v := range m {
if !omitFunc(k, v) {
result[k] = v
} else {
removedKeys = append(removedKeys, k)
}
}
return result, removedKeys
}
// OmitKeys 省略指定键的的元素
func OmitKeys[M ~map[K]V, K Hashable, V any](m M, keys ...K) M {
omitKey := map[K]struct{}{}
for _, k := range keys {
omitKey[k] = struct{}{}
}
result := M{}
var exists bool
for k, v := range m {
if _, exists = omitKey[k]; !exists {
result[k] = v
}
}
return result
}
// ContainsAnyKey 是否包含任意键
func ContainsAnyKey[M ~map[K]V, K Hashable, V any](m M, keys ...K) bool {
var exists bool
for _, key := range keys {
if _, exists = m[key]; exists {
return true
}
}
return false
}
// ContainsAllKey 是否包含所有键
func ContainsAllKey[M ~map[K]V, K Hashable, V any](m M, keys ...K) bool {
var exists bool
for _, key := range keys {
if _, exists = m[key]; !exists {
return false
}
}
return true
}
// AnyMatch 是否任意元素符合条件
func AnyMatch[M ~map[K]V, K Hashable, V any](m M, matchFunc func(k K, v V) bool) bool {
for k, v := range m {
if matchFunc(k, v) {
return true
}
}
return false
}
// AllMatch 是否所有元素符合条件
func AllMatch[M ~map[K]V, K Hashable, V any](m M, matchFunc func(k K, v V) bool) bool {
for k, v := range m {
if !matchFunc(k, v) {
return false
}
}
return true
}
// Reduce 累计
func Reduce[M ~map[K]V, K Hashable, V any, R any](m M, init R, reduceFunc func(R, K, V) R) R {
result := init
for k, v := range m {
result = reduceFunc(result, k, v)
}
return result
}
// ToSlice 键值对转切片
func ToSlice[M ~map[K]V, K Hashable, V any, R any](m M, mapFunc func(k K) R) []R {
ret := make([]R, 0, len(m))
for k := range m {
ret = append(ret, mapFunc(k))
}
return ret
}
// Filter 筛选出指定条件的所有元素
func Filter[M ~map[K]V, K Hashable, V any](m M, filterFunc func(k K) bool) M {
ret := make(M, len(m))
for k, v := range m {
if filterFunc(k) {
ret[k] = v
}
}
return ret
}
// FilterToSlice 键值对筛选并转切片
func FilterToSlice[M ~map[K]V, K Hashable, V any, R any](m M, mapFunc func(k K) (R, bool)) []R {
ret := make([]R, 0, len(m))
for k := range m {
if v, filter := mapFunc(k); filter {
ret = append(ret, v)
}
}
return ret
}
// FilterKey 筛选出指定条件的所有键
func FilterKey[M ~map[K]V, K Hashable, V any](m M, filterFunc func(k K) bool) []K {
ret := make([]K, 0, len(m))
for k := range m {
if filterFunc(k) {
ret = append(ret, k)
}
}
return ret
}
// Clone 复制键值对
func Clone[M ~map[K]V, K Hashable, V any](src M) M {
dest := make(M, len(src))
for k, v := range src {
dest[k] = v
}
return dest
}
// Reverse 键->值映射翻转为值->键映射(如果重复则覆盖最后的)
func Reverse[M ~map[K]V, K Hashable, V Hashable](src M) map[V]K {
dest := make(map[V]K, len(src))
for k, v := range src {
dest[v] = k
}
return dest
}
// ReverseAll 键->值映射翻转为值->键列表映射
func ReverseAll[M ~map[K]V, K Hashable, V Hashable](src M) map[V][]K {
dest := make(map[V][]K, len(src))
for k, v := range src {
dest[v] = append(dest[v], k)
}
return dest
}
// RemoveIf 移除指定条件的键
func RemoveIf[M ~map[K]V, K Hashable, V any](src M, cond func(key K) bool) {
for k := range src {
if cond(k) {
delete(src, k)
}
}
}

View File

@ -0,0 +1,103 @@
package rand
import (
"math/rand"
"strings"
"sync"
"time"
)
// 随机对象缓存池(解决自带随机函数全局抢锁问题)
var randObjectPool = sync.Pool{
New: func() interface{} {
return rand.New(rand.NewSource(time.Now().UnixNano()))
},
}
var lowerChar = []rune("abcdefghijklmnopqrstuvwxyz") // strings.Split("abcdefghijklmnopqrstuvwxyz", "")
var upperChar = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ") // strings.Split("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "")
var numberChar = []rune("0123456789") // strings.Split("0123456789", "")
var numberAndChar = append(lowerChar, numberChar...)
func init() {
rand.Seed(time.Now().UnixNano())
}
func Intn[T ~int](n T) T {
r := randObjectPool.Get()
res := r.(*rand.Rand).Intn(int(n))
randObjectPool.Put(r)
return T(res)
}
func IntnCount[T ~int](n T, count int) []T {
res := make([]T, count)
r := randObjectPool.Get()
for i := 0; i < count; i++ {
res[i] = T(r.(*rand.Rand).Intn(int(n)))
}
randObjectPool.Put(r)
return res
}
// Int31n 生成<n的32位整形
func Int31n[T ~int32](n T) T {
r := randObjectPool.Get()
res := r.(*rand.Rand).Int31n(int32(n))
randObjectPool.Put(r)
return T(res)
}
// Int63n 生成小于n的64位整形
func Int63n[T ~int64](n T) T {
r := randObjectPool.Get()
res := r.(*rand.Rand).Int63n(int64(n))
randObjectPool.Put(r)
return T(res)
}
// RangeInt 获取范围内的随机整数[min, max)
func RangeInt[T ~int](min, max T) T {
if min > max {
min, max = max, min
}
return Intn(max-min) + min
}
// RangeString 生成随机字符串
func RangeString(charSet []rune, n int) string {
r := randObjectPool.Get()
res := strings.Builder{}
size := len(charSet)
for i := 0; i < n; i++ {
res.WriteRune(charSet[r.(*rand.Rand).Intn(size)])
}
randObjectPool.Put(r)
return res.String()
}
// LowerString 生成随机指定长度小写字母
func LowerString(n int) string {
return RangeString(lowerChar, n)
}
// UpperString 生成随机指定长度大写字母
func UpperString(n int) string {
return RangeString(upperChar, n)
}
// NumberString 生成随机指定长度数字字符串
func NumberString(n int) string {
return RangeString(numberChar, n)
}
// CharNumberString 生成随机指定长度小写字母和数字
func CharNumberString(n int) string {
return RangeString(numberAndChar, n)
}
// Shuffle 执行指定次数打乱
func Shuffle(n int, swap func(i, j int)) {
r := randObjectPool.Get()
r.(*rand.Rand).Shuffle(n, swap)
}

View File

@ -0,0 +1,102 @@
package rand
import (
"github.com/google/go-cmp/cmp"
"math/rand"
"sync"
"time"
)
// WeightObject 权重单项
type WeightObject[T any] struct {
Obj T
Weight int
}
// WeightRandom 根据权重随机
type WeightRandom[T any] struct {
WeightObject []WeightObject[T]
totalWeight int
randObj *rand.Rand
lk sync.Mutex
}
func NewWeightRandom[T any]() *WeightRandom[T] {
return &WeightRandom[T]{
WeightObject: []WeightObject[T]{},
totalWeight: 0,
randObj: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
func (w *WeightRandom[T]) Add(object T, weight int) {
weightObj := WeightObject[T]{
Obj: object,
Weight: weight,
}
w.AddObject(weightObj)
}
// AddObject 添加单个权重对象
func (w *WeightRandom[T]) AddObject(weightObject WeightObject[T]) {
if weightObject.Weight <= 0 {
return
}
exists := false
for i, object := range w.WeightObject {
if cmp.Equal(weightObject.Obj, object.Obj) {
// 已经存在, 覆盖权重
w.subWeight(object.Weight)
w.WeightObject[i].Weight = weightObject.Weight
w.addWeight(weightObject.Weight)
exists = true
break
}
}
if !exists {
// 已经存在, 覆盖权重
w.WeightObject = append(w.WeightObject, weightObject)
w.addWeight(weightObject.Weight)
}
}
// AddObjects 添加多个权重对象
func (w *WeightRandom[T]) AddObjects(object []WeightObject[T]) {
for _, weightObject := range object {
w.AddObject(weightObject)
}
}
func (w *WeightRandom[T]) addWeight(weight int) {
if w.totalWeight < 0 {
w.totalWeight = 0
}
w.totalWeight += weight
}
func (w *WeightRandom[T]) subWeight(weight int) {
if w.totalWeight-weight < 0 {
w.totalWeight = 0
} else {
w.totalWeight -= weight
}
}
// Next 通过权重随机到下一个
func (w *WeightRandom[T]) Next() T {
if w.totalWeight > 0 {
w.lk.Lock()
randomWeight := w.randObj.Intn(w.totalWeight)
w.lk.Unlock()
weightCount := 0
for _, object := range w.WeightObject {
weightCount += object.Weight
if weightCount > randomWeight {
return object.Obj
}
}
}
var noop T
return noop
}

View File

@ -0,0 +1,38 @@
package redis
import (
"context"
"github.com/redis/go-redis/v9"
"log"
"net"
)
type LogHook struct {
name string
}
func NewHook(name string) LogHook {
return LogHook{
name: name,
}
}
func (LogHook) DialHook(next redis.DialHook) redis.DialHook {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
return next(ctx, network, addr)
}
}
func (LogHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
log.Println(cmd.String())
return next(ctx, cmd)
}
}
func (LogHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error {
for _, cmd := range cmds {
log.Println(cmd.String())
}
return next(ctx, cmds)
}
}

View File

@ -0,0 +1,467 @@
package sliceutil
import (
"sort"
"strconv"
"strings"
. "tinyrdm/backend/utils"
"tinyrdm/backend/utils/rand"
)
// Get 获取指定索引的值, 如果不存在则返回默认值
func Get[S ~[]T, T any](arr S, index int, defaultVal T) T {
if index < 0 || index >= len(arr) {
return defaultVal
}
return arr[index]
}
// Remove 删除指定索引的元素
func Remove[S ~[]T, T any](arr S, index int) S {
return append(arr[:index], arr[index+1:]...)
}
// RemoveIf 移除指定条件的元素
func RemoveIf[S ~[]T, T any](arr S, cond func(T) bool) S {
l := len(arr)
if l <= 0 {
return arr
}
for i := l - 1; i >= 0; i-- {
if cond(arr[i]) {
arr = append(arr[:i], arr[i+1:]...)
}
}
return arr
}
// RemoveRange 删除从[from, to]部分元素
func RemoveRange[S ~[]T, T any](arr S, from, to int) S {
return append(arr[:from], arr[to:]...)
}
// Find 查找指定条件的元素第一个出现位置
func Find[S ~[]T, T any](arr S, matchFunc func(int) bool) (int, bool) {
total := len(arr)
for i := 0; i < total; i++ {
if matchFunc(i) {
return i, true
}
}
return -1, false
}
// AnyMatch 判断是否有任意元素符合条件
func AnyMatch[S ~[]T, T any](arr S, matchFunc func(int) bool) bool {
total := len(arr)
if total > 0 {
for i := 0; i < total; i++ {
if matchFunc(i) {
return true
}
}
}
return false
}
// AllMatch 判断是否所有元素都符合条件
func AllMatch[S ~[]T, T any](arr S, matchFunc func(int) bool) bool {
total := len(arr)
for i := 0; i < total; i++ {
if !matchFunc(i) {
return false
}
}
return true
}
// Equals 比较两个切片内容是否完全一致
func Equals[S ~[]T, T comparable](arr1, arr2 S) bool {
if &arr1 == &arr2 {
return true
}
len1, len2 := len(arr1), len(arr2)
if len1 != len2 {
return false
}
for i := 0; i < len1; i++ {
if arr1[i] != arr2[i] {
return false
}
}
return true
}
// Contains 判断数组是否包含指定元素
func Contains[S ~[]T, T Hashable](arr S, elem T) bool {
return AnyMatch(arr, func(idx int) bool {
return arr[idx] == elem
})
}
// ContainsAny 判断数组是否包含任意指定元素
func ContainsAny[S ~[]T, T Hashable](arr S, elems ...T) bool {
for _, elem := range elems {
if Contains(arr, elem) {
return true
}
}
return false
}
// ContainsAll 判断数组是否包含所有指定元素
func ContainsAll[S ~[]T, T Hashable](arr S, elems ...T) bool {
for _, elem := range elems {
if !Contains(arr, elem) {
return false
}
}
return true
}
// Filter 筛选出符合指定条件的所有元素
func Filter[S ~[]T, T any](arr S, filterFunc func(int) bool) []T {
total := len(arr)
var result []T
for i := 0; i < total; i++ {
if filterFunc(i) {
result = append(result, arr[i])
}
}
return result
}
// Map 数组映射转换
func Map[S ~[]T, T any, R any](arr S, mappingFunc func(int) R) []R {
total := len(arr)
result := make([]R, total)
for i := 0; i < total; i++ {
result[i] = mappingFunc(i)
}
return result
}
// FilterMap 数组过滤和映射转换
func FilterMap[S ~[]T, T any, R any](arr S, mappingFunc func(int) (R, bool)) []R {
total := len(arr)
result := make([]R, 0, total)
var filter bool
var mapItem R
for i := 0; i < total; i++ {
if mapItem, filter = mappingFunc(i); filter {
result = append(result, mapItem)
}
}
return result
}
// ToMap 数组转键值对
func ToMap[S ~[]T, T any, K Hashable, V any](arr S, mappingFunc func(int) (K, V)) map[K]V {
total := len(arr)
result := map[K]V{}
for i := 0; i < total; i++ {
key, val := mappingFunc(i)
result[key] = val
}
return result
}
// Flat 二维数组扁平化
func Flat[T any](arr [][]T) []T {
total := len(arr)
var result []T
for i := 0; i < total; i++ {
subTotal := len(arr[i])
for j := 0; j < subTotal; j++ {
result = append(result, arr[i][j])
}
}
return result
}
// FlatMap 二维数组扁平化映射
func FlatMap[T any, R any](arr [][]T, mappingFunc func(int, int) R) []R {
total := len(arr)
var result []R
for i := 0; i < total; i++ {
subTotal := len(arr[i])
for j := 0; j < subTotal; j++ {
result = append(result, mappingFunc(i, j))
}
}
return result
}
func FlatValueMap[T Hashable](arr [][]T) []T {
return FlatMap(arr, func(i, j int) T {
return arr[i][j]
})
}
// Reduce 数组累计
func Reduce[S ~[]T, T any, R any](arr S, init R, reduceFunc func(R, T) R) R {
result := init
for _, item := range arr {
result = reduceFunc(result, item)
}
return result
}
// Reverse 反转数组(会修改原数组)
func Reverse[S ~[]T, T any](arr S) S {
total := len(arr)
for i := 0; i < total/2; i++ {
arr[i], arr[total-i-1] = arr[total-i-1], arr[i]
}
return arr
}
// Join 数组拼接转字符串
func Join[S ~[]T, T any](arr S, sep string, toStringFunc func(int) string) string {
total := len(arr)
if total <= 0 {
return ""
}
if total == 1 {
return toStringFunc(0)
}
sb := strings.Builder{}
for i := 0; i < total; i++ {
if i != 0 {
sb.WriteString(sep)
}
sb.WriteString(toStringFunc(i))
}
return sb.String()
}
// JoinString 字符串数组拼接成字符串
func JoinString(arr []string, sep string) string {
return Join(arr, sep, func(idx int) string {
return arr[idx]
})
}
// JoinInt 整形数组拼接转字符串
func JoinInt(arr []int, sep string) string {
return Join(arr, sep, func(idx int) string {
return strconv.Itoa(arr[idx])
})
}
// Unique 数组去重
func Unique[S ~[]T, T Hashable](arr S) S {
result := make(S, 0, len(arr))
uniKeys := map[T]struct{}{}
var exists bool
for _, item := range arr {
if _, exists = uniKeys[item]; !exists {
uniKeys[item] = struct{}{}
result = append(result, item)
}
}
return result
}
// UniqueEx 数组去重(任意类型)
// @param toKeyFunc 数组元素转为唯一标识字符串函数, 如转为哈希值等
func UniqueEx[S ~[]T, T any](arr S, toKeyFunc func(i int) string) S {
result := make(S, 0, len(arr))
keyArr := Map(arr, toKeyFunc)
uniKeys := map[string]struct{}{}
var exists bool
for i, item := range arr {
if _, exists = uniKeys[keyArr[i]]; !exists {
uniKeys[keyArr[i]] = struct{}{}
result = append(result, item)
}
}
return result
}
// Sort 顺序排序(会修改原数组)
func Sort[S ~[]T, T Hashable](arr S) S {
sort.Slice(arr, func(i, j int) bool {
return arr[i] <= arr[j]
})
return arr
}
// SortDesc 倒序排序(会修改原数组)
func SortDesc[S ~[]T, T Hashable](arr S) S {
sort.Slice(arr, func(i, j int) bool {
return arr[i] > arr[j]
})
return arr
}
// Union 返回两个切片共同拥有的元素
func Union[S ~[]T, T Hashable](arr1 S, arr2 S) S {
hashArr, compArr := arr1, arr2
if len(arr1) < len(arr2) {
hashArr, compArr = compArr, hashArr
}
hash := map[T]struct{}{}
for _, item := range hashArr {
hash[item] = struct{}{}
}
uniq := map[T]struct{}{}
ret := make(S, 0, len(compArr))
exists := false
for _, item := range compArr {
if _, exists = hash[item]; exists {
if _, exists = uniq[item]; !exists {
ret = append(ret, item)
uniq[item] = struct{}{}
}
}
}
return ret
}
// Exclude 返回不包含的元素
func Exclude[S ~[]T, T Hashable](arr1 S, arr2 S) S {
diff := make([]T, 0, len(arr1))
hash := map[T]struct{}{}
for _, item := range arr2 {
hash[item] = struct{}{}
}
for _, item := range arr1 {
if _, exists := hash[item]; !exists {
diff = append(diff, item)
}
}
return diff
}
// PadLeft 左边填充指定数量
func PadLeft[S ~[]T, T any](arr S, val T, count int) S {
prefix := make(S, count)
for i := 0; i < count; i++ {
prefix[i] = val
}
arr = append(prefix, arr...)
return arr
}
// PadRight 右边填充指定数量
func PadRight[S ~[]T, T any](arr S, val T, count int) S {
for i := 0; i < count; i++ {
arr = append(arr, val)
}
return arr
}
// RemoveLeft 移除左侧相同元素
func RemoveLeft[S ~[]T, T comparable](arr S, val T) S {
for len(arr) > 0 && arr[0] == val {
arr = arr[1:]
}
return arr
}
// RemoveRight 移除右侧相同元素
func RemoveRight[S ~[]T, T comparable](arr S, val T) S {
for {
length := len(arr)
if length > 0 && arr[length-1] == val {
arr = arr[:length]
} else {
break
}
}
return arr
}
// RandomElem 从切片中随机抽一个
func RandomElem[S ~[]T, T any](arr S) T {
l := len(arr)
if l <= 0 {
var r T
return r
}
return arr[rand.Intn(l)]
}
// RandomElems 从切片中随机抽多个
// 如果切片长度为空, 则返回空切片
func RandomElems[S ~[]T, T any](arr S, count int) []T {
l := len(arr)
ret := make([]T, 0, l)
if l <= 0 {
return ret
}
idxList := rand.IntnCount(l, count)
for _, idx := range idxList {
ret = append(ret, arr[idx])
}
return ret
}
// RandomUniqElems 从切片中随机抽多个不同的元素
// 如果切片长度为空, 则返回空切片
// 如果所需数量大于切片唯一元素数量, 则返回整个切片
func RandomUniqElems[S ~[]T, T Hashable](arr S, count int) []T {
if len(arr) <= 0 {
// 可选列表为空, 返回空切片
return []T{}
}
// 转换为集合
uniqList := Unique(arr)
uniqLen := len(uniqList)
if uniqLen <= count {
// 可选集合总数<=所需元素数量, 直接返回整个可选集合
return uniqList
}
if count >= uniqLen/2 {
// 所需唯一元素大于可选集合一半, 随机筛掉(uniqLen-count)个元素
for i := 0; i < uniqLen-count; i++ {
uniqList = Remove(uniqList, rand.Intn(uniqLen-i))
}
return uniqList
} else {
// 所需唯一元素小于可选集合一半, 随机抽取count个元素
res := make([]T, count)
var idx int
for i := 0; i < count; i++ {
idx = rand.Intn(uniqLen - i)
res[i] = uniqList[idx]
uniqList = Remove(uniqList, idx)
}
return res
}
}
// Clone 复制切片
func Clone[S ~[]T, T any](src S) S {
dest := make(S, len(src))
copy(dest, src)
return dest
}
// Count 统计制定条件元素数量
func Count[S ~[]T, T any](arr S, filter func(int) bool) int {
count := 0
for i := range arr {
if filter(i) {
count += 1
}
}
return count
}
// Group 根据分组函数对数组进行分组汇总
func Group[S ~[]T, T any, K Hashable, R any](arr S, groupFunc func(int) (K, R)) map[K][]R {
ret := map[K][]R{}
for i := range arr {
key, val := groupFunc(i)
ret[key] = append(ret[key], val)
}
return ret
}

35
build/README.md Normal file
View File

@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -0,0 +1,32 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.Name}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

27
build/darwin/Info.plist Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.Name}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
</dict>
</plist>

BIN
build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

15
build/windows/info.json Normal file
View File

@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@ -0,0 +1,105 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@ -0,0 +1,179 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

8
frontend/.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"printWidth": 120,
"tabWidth": 4,
"singleQuote": true,
"semi": false,
"bracketSameLine": false,
"endOfLine": "auto"
}

8
frontend/README.md Normal file
View File

@ -0,0 +1,8 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs,
check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)

14
frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta content='width=device-width, initial-scale=1.0' name='viewport' />
<title>Tiny RDM</title>
<!-- <link href="./src/style.scss" rel="stylesheet">-->
</head>
<body>
<div id='app'></div>
<script src='./src/main.js' type='module'></script>
</body>
</html>

3483
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "^11.8.0",
"lodash": "^4.17.21",
"pinia": "^2.1.3",
"sass": "^1.62.1",
"vue": "^3.2.37",
"vue-i18n": "^9.2.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"naive-ui": "^2.34.4",
"prettier": "^2.8.8",
"unplugin-auto-import": "^0.16.4",
"unplugin-icons": "^0.16.1",
"unplugin-vue-components": "^0.25.0",
"vite": "^4.3.0"
}
}

1
frontend/package.json.md5 Executable file
View File

@ -0,0 +1 @@
da66eb9d13a7ace25f7f75d36c2510f9

160
frontend/src/App.vue Normal file
View File

@ -0,0 +1,160 @@
<script setup>
import { get } from 'lodash'
import { computed, nextTick, onMounted, provide, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { GetPreferences } from '../wailsjs/go/storage/PreferencesStorage.js'
import ContentPane from './components/ContentPane.vue'
import NewConnDialog from './components/dialogs/NewConnDialog.vue'
import NewKeyDialog from './components/dialogs/NewKeyDialog.vue'
import PreferencesDialog from './components/dialogs/PreferencesDialog.vue'
import RenameKeyDialog from './components/dialogs/RenameKeyDialog.vue'
import SetTtlDialog from './components/dialogs/SetTtlDialog.vue'
import NavigationPane from './components/NavigationPane.vue'
import hljs from 'highlight.js/lib/core'
import json from 'highlight.js/lib/languages/json'
import plaintext from 'highlight.js/lib/languages/plaintext'
import { useThemeVars } from 'naive-ui'
import AddFieldsDialog from './components/dialogs/AddFieldsDialog.vue'
const themeVars = useThemeVars()
hljs.registerLanguage('json', json)
hljs.registerLanguage('plaintext', plaintext)
const data = reactive({
asideWith: 300,
hoverResize: false,
resizing: false,
})
const preferences = ref({})
provide('preferences', preferences)
const i18n = useI18n()
onMounted(async () => {
preferences.value = await GetPreferences()
await nextTick(() => {
i18n.locale.value = get(preferences.value, 'general.language', 'en')
})
})
// TODO: apply font size to all elements
const getFontSize = computed(() => {
return get(preferences.value, 'general.font_size', 'en')
})
const themeOverrides = {
common: {
// primaryColor: '#409EFF',
borderRadius: '4px',
borderRadiusSmall: '3px',
fontFamily: `"Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue"`,
lineHeight: 1.5,
},
Tag: {
// borderRadius: '3px'
},
}
const handleResize = (evt) => {
if (data.resizing) {
data.asideWith = Math.max(evt.clientX, 300)
}
}
const stopResize = () => {
data.resizing = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
// TODO: Save sidebar x-position
}
const startResize = () => {
data.resizing = true
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
}
const asideWidthVal = computed(() => {
return data.asideWith + 'px'
})
const dragging = computed(() => {
return data.hoverResize || data.resizing
})
</script>
<template>
<n-config-provider :hljs="hljs" :inline-theme-disabled="true" :theme-overrides="themeOverrides" class="fill-height">
<n-message-provider>
<n-dialog-provider>
<div id="app-container" :class="{ dragging: dragging }" class="flex-box-h">
<div id="app-side" :style="{ width: asideWidthVal }" class="flex-box-h flex-item">
<navigation-pane class="flex-item-expand"></navigation-pane>
<div
:class="{
'resize-divider-hover': data.hoverResize,
'resize-divider-drag': data.resizing,
}"
class="resize-divider"
@mousedown="startResize"
@mouseout="data.hoverResize = false"
@mouseover="data.hoverResize = true"
></div>
</div>
<content-pane class="flex-item-expand" />
</div>
<!-- top modal dialogs -->
<new-conn-dialog />
<new-key-dialog />
<add-fields-dialog />
<rename-key-dialog />
<set-ttl-dialog />
<preferences-dialog />
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<style lang="scss">
#app-container {
height: 100%;
overflow: hidden;
border-top: var(--border-color) 1px solid;
box-sizing: border-box;
#app-toolbar {
height: 40px;
border-bottom: var(--border-color) 1px solid;
}
#app-side {
//overflow: hidden;
height: 100%;
.resize-divider {
//height: 100%;
width: 2px;
border-left-width: 5px;
background-color: var(--border-color);
}
.resize-divider-hover {
width: 5px;
}
.resize-divider-drag {
//background-color: rgb(0, 105, 218);
width: 5px;
//background-color: var(--el-color-primary);
background-color: v-bind('themeVars.primaryColor');
}
}
}
.dragging {
cursor: col-resize !important;
}
</style>

View File

@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@ -0,0 +1,450 @@
<script setup>
import { h, nextTick, onMounted, reactive, ref } from 'vue'
import { ConnectionType } from '../consts/connection_type.js'
import useConnection from '../stores/connection.js'
import { NIcon, useDialog, useMessage } from 'naive-ui'
import ToggleFolder from './icons/ToggleFolder.vue'
import Key from './icons/Key.vue'
import ToggleDb from './icons/ToggleDb.vue'
import ToggleServer from './icons/ToggleServer.vue'
import { indexOf, remove, startsWith } from 'lodash'
import { useI18n } from 'vue-i18n'
import Refresh from './icons/Refresh.vue'
import Config from './icons/Config.vue'
import CopyLink from './icons/CopyLink.vue'
import Unlink from './icons/Unlink.vue'
import Add from './icons/Add.vue'
import Layer from './icons/Layer.vue'
import Delete from './icons/Delete.vue'
import Connect from './icons/Connect.vue'
import useDialogStore from '../stores/dialog.js'
import { ClipboardSetText } from '../../wailsjs/runtime/runtime.js'
import useTabStore from '../stores/tab.js'
const i18n = useI18n()
const loading = ref(false)
const loadingConnections = ref(false)
const expandedKeys = ref([])
const connectionStore = useConnection()
const tabStore = useTabStore()
const dialogStore = useDialogStore()
const showContextMenu = ref(false)
const contextPos = reactive({ x: 0, y: 0 })
const contextMenuOptions = ref(null)
const currentContextNode = ref(null)
const renderIcon = (icon) => {
return () => {
return h(NIcon, null, {
default: () => h(icon),
})
}
}
const menuOptions = {
[ConnectionType.Group]: ({ opened }) => [
{
key: 'group_reload',
label: i18n.t('config_conn_group'),
icon: renderIcon(Config),
},
{
key: 'group_delete',
label: i18n.t('remove_conn_group'),
icon: renderIcon(Delete),
},
],
[ConnectionType.Server]: ({ connected }) => {
if (connected) {
return [
{
key: 'server_reload',
label: i18n.t('reload'),
icon: renderIcon(Refresh),
},
{
key: 'server_disconnect',
label: i18n.t('disconnect'),
icon: renderIcon(Unlink),
},
{
key: 'server_dup',
label: i18n.t('dup_conn'),
icon: renderIcon(CopyLink),
},
{
key: 'server_config',
label: i18n.t('config_conn'),
icon: renderIcon(Config),
},
{
type: 'divider',
key: 'd1',
},
{
key: 'server_remove',
label: i18n.t('remove_conn'),
icon: renderIcon(Delete),
},
]
} else {
return [
{
key: 'server_open',
label: i18n.t('open_connection'),
icon: renderIcon(Connect),
},
]
}
},
[ConnectionType.RedisDB]: ({ opened }) => {
if (opened) {
return [
{
key: 'db_reload',
label: i18n.t('reload'),
icon: renderIcon(Refresh),
},
{
key: 'db_newkey',
label: i18n.t('new_key'),
icon: renderIcon(Add),
},
]
} else {
return [
{
key: 'db_open',
label: i18n.t('open_db'),
icon: renderIcon(Connect),
},
]
}
},
[ConnectionType.RedisKey]: () => [
{
key: 'key_reload',
label: i18n.t('reload'),
icon: renderIcon(Refresh),
},
{
key: 'key_newkey',
label: i18n.t('new_key'),
icon: renderIcon(Add),
},
{
key: 'key_copy',
label: i18n.t('copy_path'),
icon: renderIcon(CopyLink),
},
{
type: 'divider',
key: 'd1',
},
{
key: 'key_remove',
label: i18n.t('remove_path'),
icon: renderIcon(Delete),
},
],
[ConnectionType.RedisValue]: () => [
{
key: 'value_reload',
label: i18n.t('reload'),
icon: renderIcon(Refresh),
},
{
key: 'value_copy',
label: i18n.t('copy_key'),
icon: renderIcon(CopyLink),
},
{
type: 'divider',
key: 'd1',
},
{
key: 'value_remove',
label: i18n.t('remove_key'),
icon: renderIcon(Delete),
},
],
}
const renderContextLabel = (option) => {
return h('div', { class: 'context-menu-item' }, option.label)
}
onMounted(async () => {
try {
// TODO: Show loading list status
loadingConnections.value = true
nextTick(connectionStore.initConnection)
} finally {
loadingConnections.value = false
}
})
const expandKey = (key) => {
const idx = indexOf(expandedKeys.value, key)
if (idx === -1) {
expandedKeys.value.push(key)
} else {
expandedKeys.value.splice(idx, 1)
}
}
const collapseKeyAndChildren = (key) => {
remove(expandedKeys.value, (k) => startsWith(k, key))
// console.log(key)
// const idx = indexOf(expandedKeys.value, key)
// console.log(JSON.stringify(expandedKeys.value))
// if (idx !== -1) {
// expandedKeys.value.splice(idx, 1)
// return true
// }
// return false
}
const message = useMessage()
const dialog = useDialog()
const onUpdateExpanded = (value, option, meta) => {
expandedKeys.value = value
if (!meta.node) {
return
}
// console.log(JSON.stringify(meta))
switch (meta.action) {
case 'expand':
meta.node.expanded = true
break
case 'collapse':
meta.node.expanded = false
break
}
}
const renderPrefix = ({ option }) => {
switch (option.type) {
case ConnectionType.Group:
return h(
NIcon,
{ size: 20 },
{
default: () => h(ToggleFolder, { modelValue: option.expanded === true }),
}
)
case ConnectionType.Server:
return h(
NIcon,
{ size: 20 },
{
default: () => h(ToggleServer, { modelValue: option.connected === true }),
}
)
case ConnectionType.RedisDB:
return h(
NIcon,
{ size: 20 },
{
default: () => h(ToggleDb, { modelValue: option.opened === true }),
}
)
case ConnectionType.RedisKey:
return h(
NIcon,
{ size: 20 },
{
default: () => h(Layer),
}
)
case ConnectionType.RedisValue:
return h(
NIcon,
{ size: 20 },
{
default: () => h(Key),
}
)
}
}
const renderLabel = ({ option }) => {
switch (option.type) {
case ConnectionType.RedisDB:
case ConnectionType.RedisKey:
return `${option.label} (${option.keys || 0})`
// case ConnectionType.RedisValue:
// return `[${option.keyType}]${option.label}`
}
return option.label
}
const renderSuffix = ({ option }) => {
// return h(NButton,
// { text: true, type: 'primary' },
// { default: () => h(Key) })
}
const nodeProps = ({ option }) => {
return {
onClick() {
connectionStore.select(option)
// console.log('[click]:' + JSON.stringify(option))
},
onDblclick: async () => {
if (loading.value) {
console.warn('TODO: alert to ignore double click when loading')
return
}
switch (option.type) {
case ConnectionType.Server:
option.isLeaf = false
break
case ConnectionType.RedisDB:
option.isLeaf = false
}
// default handle is expand current node
nextTick().then(() => expandKey(option.key))
},
onContextmenu(e) {
e.preventDefault()
const mop = menuOptions[option.type]
if (mop == null) {
return
}
showContextMenu.value = false
nextTick().then(() => {
contextMenuOptions.value = mop(option)
currentContextNode.value = option
contextPos.x = e.clientX
contextPos.y = e.clientY
showContextMenu.value = true
})
},
// onMouseover() {
// console.log('mouse over')
// }
}
}
const onLoadTree = async (node) => {
switch (node.type) {
case ConnectionType.Server:
loading.value = true
try {
await connectionStore.openConnection(node.name)
} catch (e) {
message.error(e.message)
node.isLeaf = undefined
} finally {
loading.value = false
}
break
case ConnectionType.RedisDB:
loading.value = true
try {
await connectionStore.openDatabase(node.name, node.db)
} catch (e) {
message.error(e.message)
node.isLeaf = undefined
} finally {
loading.value = false
}
break
}
}
const handleSelectContextMenu = (key) => {
showContextMenu.value = false
const { name, db, key: nodeKey, redisKey } = currentContextNode.value
switch (key) {
case 'server_disconnect':
connectionStore.closeConnection(nodeKey).then((success) => {
if (success) {
collapseKeyAndChildren(nodeKey)
tabStore.removeTabByName(name)
}
})
break
// case 'server_reload':
// case 'db_reload':
// connectionStore.loadKeyValue()
// break
case 'db_newkey':
case 'key_newkey':
dialogStore.openNewKeyDialog(redisKey, name, db)
break
case 'key_remove':
case 'value_remove':
dialog.warning({
title: i18n.t('warning'),
content: i18n.t('delete_key_tip', { key: redisKey }),
closable: false,
autoFocus: false,
transformOrigin: 'center',
positiveText: i18n.t('confirm'),
negativeText: i18n.t('cancel'),
onPositiveClick: () => {
connectionStore.removeKey(name, db, redisKey).then((success) => {
if (success) {
message.success(i18n.t('delete_key_succ', { key: redisKey }))
}
})
},
})
break
case 'key_copy':
case 'value_copy':
ClipboardSetText(redisKey)
.then((succ) => {
if (succ) {
message.success(i18n.t('copy_succ'))
}
})
.catch((e) => {
message.error(e.message)
})
break
default:
console.warn('TODO: handle context menu:' + key)
}
}
const handleOutsideContextMenu = () => {
showContextMenu.value = false
}
</script>
<template>
<n-tree
:block-line="true"
:block-node="true"
:data="connectionStore.connections"
:expand-on-click="false"
:expanded-keys="expandedKeys"
:node-props="nodeProps"
:on-load="onLoadTree"
:on-update:expanded-keys="onUpdateExpanded"
:render-label="renderLabel"
:render-prefix="renderPrefix"
:render-suffix="renderSuffix"
block-line
class="fill-height"
virtual-scroll
/>
<n-dropdown
:animated="false"
:options="contextMenuOptions"
:render-label="renderContextLabel"
:show="showContextMenu"
:x="contextPos.x"
:y="contextPos.y"
placement="bottom-start"
trigger="manual"
@clickoutside="handleOutsideContextMenu"
@select="handleSelectContextMenu"
/>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,111 @@
<script setup>
import { computed } from 'vue'
import { types } from '../consts/support_redis_type.js'
import ContentValueHash from './content_value/ContentValueHash.vue'
import ContentValueList from './content_value/ContentValueList.vue'
import ContentValueString from './content_value/ContentValueString.vue'
import ContentValueSet from './content_value/ContentValueSet.vue'
import ContentValueZset from './content_value/ContentValueZset.vue'
import { isEmpty, map, toUpper } from 'lodash'
import useTabStore from '../stores/tab.js'
const valueComponents = {
[types.STRING]: ContentValueString,
[types.HASH]: ContentValueHash,
[types.LIST]: ContentValueList,
[types.SET]: ContentValueSet,
[types.ZSET]: ContentValueZset,
}
const tabStore = useTabStore()
const tab = computed(() =>
map(tabStore.tabs, (item) => ({
key: item.name,
label: item.title,
}))
)
/**
*
* @type {ComputedRef<TabItem>}
*/
const tabContent = computed(() => {
const tab = tabStore.currentTab
if (tab == null) {
return null
}
return {
name: tab.name,
type: toUpper(tab.type),
db: tab.db,
keyPath: tab.key,
ttl: tab.ttl,
value: tab.value,
}
})
const onUpdateValue = (tabIndex) => {
tabStore.switchTab(tabIndex)
}
const onAddTab = () => {
tabStore.newBlankTab()
}
const onCloseTab = (tabIndex) => {
tabStore.removeTab(tabIndex)
console.log('TODO: close connection also')
}
</script>
<template>
<div class="content-container flex-box-v">
<!-- <content-tab :model-value="tab"></content-tab>-->
<n-tabs
v-model:value="tabStore.activatedIndex"
:closable="tab.length > 1"
addable
size="small"
type="card"
@add="onAddTab"
@close="onCloseTab"
@update:value="onUpdateValue"
>
<n-tab v-for="(t, i) in tab" :key="i" :name="i">
<n-ellipsis style="max-width: 150px">{{ t.label }}</n-ellipsis>
</n-tab>
</n-tabs>
<!-- add loading status -->
<component
:is="valueComponents[tabContent.type]"
v-if="tabContent != null && !isEmpty(tabContent.keyPath)"
:db="tabContent.db"
:key-path="tabContent.keyPath"
:name="tabContent.name"
:ttl="tabContent.ttl"
:value="tabContent.value"
/>
<div v-else class="flex-item-expand flex-box-v">
<n-empty :description="$t('empty_tab_content')" class="empty-content" />
</div>
</div>
</template>
<style lang="scss" scoped>
.content-container {
height: 100%;
overflow: hidden;
background-color: var(--bg-color);
padding-top: 2px;
padding-bottom: 5px;
box-sizing: border-box;
}
.empty-content {
height: 100%;
justify-content: center;
}
.tab-content {
}
</style>

View File

@ -0,0 +1,139 @@
<script setup>
import { ref, watch } from 'vue'
import useConnectionStore from '../stores/connection.js'
import { throttle } from 'lodash'
import { ConnectionType } from '../consts/connection_type.js'
import Close from './icons/Close.vue'
const emit = defineEmits(['switchTab', 'closeTab', 'update:modelValue'])
const props = defineProps({
selectedIndex: {
type: Number,
default: 0,
},
modelValue: {
type: Object,
default: [
{
// label: 'tab1',
// key: 'key',
// bgColor: 'white',
},
],
},
})
const connectionStore = useConnectionStore()
const onCurrentSelectChange = ({ type, group = '', server = '', db = 0, key = '' }) => {
console.log(`group: ${group}\n server: ${server}\n db: ${db}\n key: ${key}`)
if (type === ConnectionType.RedisValue) {
// load and update content value
}
}
watch(() => connectionStore.currentSelect, throttle(onCurrentSelectChange, 1000))
const items = ref(props.modelValue)
const selIndex = ref(props.selectedIndex)
const onClickTab = (idx, key) => {
if (idx !== selIndex.value) {
selIndex.value = idx
emit('update:modelValue', idx, key)
}
}
const onCloseTab = (idx, key) => {
const removed = items.value.splice(idx, 1)
if (removed.length <= 0) {
return
}
// Update select index if removed index equal current selected
if (selIndex.value === idx) {
selIndex.value -= 1
if (selIndex.value < 0 && items.value.length > 0) {
selIndex.value = 0
}
}
emit('update:modelValue', items)
emit('closeTab', idx, key)
}
</script>
<template>
<!-- TODO: 检查标签是否太多, 左右两边显示左右切换翻页按钮 -->
<div class="content-tab flex-box-h">
<div
v-for="(item, i) in props.modelValue"
:key="item.key"
:class="{ 'content-tab_selected': selIndex === i }"
:style="{ backgroundColor: item.bgColor || '' }"
:title="item.label"
class="content-tab_item flex-item-expand icon-btn flex-box-h"
@click="onClickTab(i, item.key)"
>
<n-icon :component="Close" class="content-tab_item-close" size="20" @click.stop="onCloseTab(i, item.key)" />
<div class="content-tab_item-label ellipsis flex-item-expand">
{{ item.label }}
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.content-tab {
align-items: center;
//justify-content: center;
width: 100%;
height: 40px;
overflow: hidden;
font-size: 14px;
&_item {
flex: 1 0;
overflow: hidden;
align-items: center;
justify-content: center;
gap: 3px;
height: 100%;
box-sizing: border-box;
background-color: var(--bg-color-page);
color: var(--text-color-secondary);
padding: 0 5px;
//border-top: var(--el-border-color) 1px solid;
border-right: var(--border-color) 1px solid;
transition: all var(--transition-duration-fast) var(--transition-function-ease-in-out-bezier);
&-label {
text-align: center;
}
&-close {
//display: none;
display: inline-flex;
width: 0;
transition: width 0.3s;
&:hover {
background-color: rgb(176, 177, 182, 0.4);
}
}
&:hover {
.content-tab_item-close {
//display: block;
width: 20px;
transition: width 0.3s;
}
}
}
&_selected {
border-top: #409eff 4px solid !important;
background-color: #ffffff;
color: #303133;
}
}
</style>

View File

@ -0,0 +1,107 @@
<script setup>
import { validType } from '../consts/support_redis_type'
import useDialog from '../stores/dialog'
import Delete from './icons/Delete.vue'
import Edit from './icons/Edit.vue'
import Refresh from './icons/Refresh.vue'
import Timer from './icons/Timer.vue'
import RedisTypeTag from './RedisTypeTag.vue'
import useConnectionStore from '../stores/connection.js'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import IconButton from './IconButton.vue'
const props = defineProps({
server: String,
db: Number,
keyType: {
type: String,
validator(value) {
return validType(value)
},
default: 'STRING',
},
keyPath: String,
ttl: {
type: Number,
default: -1,
},
})
const dialogStore = useDialog()
const connectionStore = useConnectionStore()
const message = useMessage()
const i18n = useI18n()
const onReloadKey = () => {
connectionStore.loadKeyValue(props.server, props.db, props.keyPath)
}
const onConfirmDelete = async () => {
const success = await connectionStore.removeKey(props.server, props.db, props.keyPath)
if (success) {
message.success(i18n.t('delete_key_succ', { key: props.keyPath }))
}
}
</script>
<template>
<div class="content-toolbar flex-box-h">
<n-input-group>
<redis-type-tag :type="props.keyType" size="large"></redis-type-tag>
<n-input v-model:value="props.keyPath">
<template #suffix>
<icon-button :icon="Refresh" :tooltip="$t('reload_key')" size="18" @click="onReloadKey" />
</template>
</n-input>
</n-input-group>
<n-button-group>
<n-tooltip>
<template #trigger>
<n-button @click="dialogStore.openTTLDialog(props.ttl)">
<template #icon>
<n-icon :component="Timer" size="18" />
</template>
<template v-if="ttl < 0">
{{ $t('forever') }}
</template>
<template v-else> {{ ttl }} {{ $t('second') }}</template>
</n-button>
</template>
TTL
</n-tooltip>
<n-button @click="dialogStore.openRenameKeyDialog(props.server, props.db, props.keyPath)">
<template #icon>
<n-icon :component="Edit" size="18" />
</template>
{{ $t('rename_key') }}
</n-button>
</n-button-group>
<n-tooltip>
<template #trigger>
<n-popconfirm
:negative-text="$t('cancel')"
:positive-text="$t('confirm')"
@positive-click="onConfirmDelete"
>
<template #trigger>
<n-button>
<template #icon>
<n-icon :component="Delete" size="18" />
</template>
</n-button>
</template>
{{ $t('delete_key_tip', { key: props.keyPath }) }}
</n-popconfirm>
</template>
{{ $t('delete_key') }}
</n-tooltip>
</div>
</template>
<style lang="scss" scoped>
.content-toolbar {
align-items: center;
gap: 5px;
}
</style>

View File

@ -0,0 +1,39 @@
<script setup>
import IconButton from './IconButton.vue'
import Delete from './icons/Delete.vue'
import Edit from './icons/Edit.vue'
import Close from './icons/Close.vue'
import Save from './icons/Save.vue'
const props = defineProps({
bindKey: String,
editing: Boolean,
})
const emit = defineEmits(['edit', 'delete', 'save', 'cancel'])
</script>
<template>
<!-- TODO: support multiple save -->
<div v-if="props.editing" class="flex-box-h edit-column-func">
<icon-button :icon="Save" @click="emit('save')" />
<icon-button :icon="Close" @click="emit('cancel')" />
</div>
<div v-else class="flex-box-h edit-column-func">
<icon-button :icon="Edit" @click="emit('edit')" />
<n-popconfirm :negative-text="$t('cancel')" :positive-text="$t('confirm')" @positive-click="emit('delete')">
<template #trigger>
<icon-button :icon="Delete" />
</template>
{{ $t('delete_key_tip', { key: props.bindKey }) }}
</n-popconfirm>
</div>
</template>
<style lang="scss">
.edit-column-func {
align-items: center;
justify-content: center;
gap: 10px;
}
</style>

View File

@ -0,0 +1,27 @@
<script setup>
import { NInput } from 'naive-ui'
const props = defineProps({
isEdit: Boolean,
value: [String, Number],
onUpdateValue: [Function, Array],
})
const emit = defineEmits(['update:value'])
const handleUpdateValue = (val) => {
emit('update:value', val)
}
</script>
<template>
<div style="min-height: 22px">
<template v-if="props.isEdit">
<!-- TODO: ADD FULL SCREEN EDIT SUPPORT -->
<n-input :value="props.value" @update:value="handleUpdateValue" />
</template>
<template v-else>
{{ props.value }}
</template>
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
import { computed } from 'vue'
import { NIcon } from 'naive-ui'
const emit = defineEmits(['click'])
const props = defineProps({
tooltip: {
type: String,
},
tTooltip: {
type: String,
},
icon: {
type: [String, Object],
},
size: {
type: [Number, String],
default: 20,
},
color: {
type: String,
default: 'currentColor',
},
strokeWidth: {
type: [Number, String],
default: 3,
},
})
const hasTooltip = computed(() => {
return props.tooltip || props.tTooltip
})
</script>
<template>
<n-tooltip v-if="hasTooltip">
<template #trigger>
<n-icon :color="props.color" :size="props.size" class="icon-btn" @click="emit('click')">
<component :is="props.icon" :stroke-width="props.strokeWidth"></component>
</n-icon>
</template>
{{ props.tTooltip ? $t(props.tTooltip) : props.tooltip }}
</n-tooltip>
<n-icon v-else :color="props.color" :size="props.size" class="icon-btn" @click="emit('click')">
<component :is="props.icon" :stroke-width="props.strokeWidth"></component>
</n-icon>
</template>
<style lang="scss"></style>

View File

@ -0,0 +1,62 @@
<script setup>
import useDialogStore from '../stores/dialog.js'
import { NIcon } from 'naive-ui'
import AddGroup from './icons/AddGroup.vue'
import AddLink from './icons/AddLink.vue'
import Sort from './icons/Sort.vue'
import ConnectionsTree from './ConnectionsTree.vue'
import IconButton from './IconButton.vue'
import Filter from './icons/Filter.vue'
const dialogStore = useDialogStore()
const onSort = () => {
dialogStore.openPreferencesDialog()
}
</script>
<template>
<div class="nav-pane-container flex-box-v">
<ConnectionsTree />
<!-- bottom function bar -->
<div class="nav-pane-bottom flex-box-h">
<icon-button
:icon="AddLink"
color="#555"
size="20"
stroke-width="4"
t-tooltip="new_conn"
@click="dialogStore.openNewDialog()"
/>
<icon-button
:icon="AddGroup"
color="#555"
size="20"
stroke-width="4"
t-tooltip="new_group"
@click="dialogStore.openNewKeyDialog('aa:bb')"
/>
<n-divider style="margin: 0 4px; --n-color: #aaa; width: 2px" vertical />
<icon-button :icon="Sort" color="#555" size="20" stroke-width="4" t-tooltip="sort_conn" @click="onSort" />
<n-input placeholder="">
<template #prefix>
<n-icon :component="Filter" color="#aaa" size="20" />
</template>
</n-input>
</div>
</div>
</template>
<style lang="scss" scoped>
.nav-pane-container {
overflow: hidden;
background-color: var(--bg-color);
.nav-pane-bottom {
align-items: center;
gap: 5px;
padding: 3px 3px 5px 5px;
}
}
</style>

View File

@ -0,0 +1,50 @@
<script setup>
import { computed } from 'vue'
import { types, validType } from '../consts/support_redis_type.js'
const props = defineProps({
type: {
type: String,
validator(value) {
return validType(value)
},
default: 'STRING',
},
color: {
type: String,
default: '',
},
size: String,
})
const color = {
[types.STRING]: '#626aef',
[types.HASH]: '#576bfa',
[types.LIST]: '#34b285',
[types.SET]: '#bb7d52',
[types.ZSET]: '#d053a5',
}
const backgroundColor = computed(() => {
return color[props.type]
})
</script>
<template>
<n-tag
:bordered="false"
:color="{ color: backgroundColor, textColor: 'white' }"
:size="props.size"
class="redis-type-tag"
strong
>
{{ props.type }}
</n-tag>
<!-- <div class="redis-type-tag flex-box-h" :style="{backgroundColor: backgroundColor}">{{ props.type }}</div>-->
</template>
<style lang="scss">
.redis-type-tag {
padding: 0 12px;
}
</style>

View File

@ -0,0 +1,14 @@
<script></script>
<template>
<div class="app-toolbar flex-box-h">
<n-button>按钮1</n-button>
<n-button>按钮2</n-button>
</div>
</template>
<style lang="scss" scoped>
.app-toolbar {
align-items: center;
}
</style>

View File

@ -0,0 +1,288 @@
<script setup>
import { computed, h, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentToolbar from '../ContentToolbar.vue'
import AddLink from '../icons/AddLink.vue'
import { NButton, NCode, NIcon, NInput, useMessage } from 'naive-ui'
import { types, types as redisTypes } from '../../consts/support_redis_type.js'
import EditableTableColumn from '../EditableTableColumn.vue'
import useConnectionStore from '../../stores/connection.js'
import useDialogStore from '../../stores/dialog.js'
const i18n = useI18n()
const filterOption = [
{
value: 1,
label: i18n.t('field'),
},
{
value: 2,
label: i18n.t('value'),
},
]
const filterType = ref(1)
const props = defineProps({
name: String,
db: Number,
keyPath: String,
ttl: {
type: Number,
default: -1,
},
value: Object,
})
const connectionStore = useConnectionStore()
const dialogStore = useDialogStore()
const keyType = redisTypes.HASH
const currentEditRow = ref({
no: 0,
key: '',
value: null,
})
const fieldColumn = reactive({
key: 'key',
title: i18n.t('field'),
align: 'center',
titleAlign: 'center',
resizable: true,
filterOptionValue: null,
filter(value, row) {
return !!~row.key.indexOf(value.toString())
},
// sorter: (row1, row2) => row1.key - row2.key,
render: (row) => {
const isEdit = currentEditRow.value.no === row.no
if (isEdit) {
return h(NInput, {
value: currentEditRow.value.key,
'onUpdate:value': (val) => {
currentEditRow.value.key = val
},
})
} else {
return row.key
}
},
})
const valueColumn = reactive({
key: 'value',
title: i18n.t('value'),
align: 'center',
titleAlign: 'center',
resizable: true,
filterOptionValue: null,
filter(value, row) {
return !!~row.value.indexOf(value.toString())
},
// sorter: (row1, row2) => row1.value - row2.value,
// ellipsis: {
// tooltip: true
// },
render: (row) => {
const isEdit = currentEditRow.value.no === row.no
if (isEdit) {
return h(NInput, {
value: currentEditRow.value.value,
type: 'textarea',
autosize: { minRow: 2, maxRows: 5 },
style: 'text-align: left;',
'onUpdate:value': (val) => {
currentEditRow.value.value = val
},
})
} else {
return h(NCode, { language: 'plaintext', wordWrap: true }, { default: () => row.value })
}
},
})
const actionColumn = {
key: 'action',
title: i18n.t('action'),
width: 100,
align: 'center',
titleAlign: 'center',
fixed: 'right',
render: (row) => {
return h(EditableTableColumn, {
editing: currentEditRow.value.no === row.no,
bindKey: row.key,
onEdit: () => {
currentEditRow.value.no = row.no
currentEditRow.value.key = row.key
currentEditRow.value.value = row.value
},
onDelete: async () => {
try {
const { success, msg } = await connectionStore.removeHashField(
props.name,
props.db,
props.keyPath,
row.key
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('delete_key_succ', { key: row.key }))
// update display value
// if (!isEmpty(removed)) {
// for (const elem of removed) {
// delete props.value[elem]
// }
// }
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
}
},
onSave: async () => {
try {
const { success, msg } = await connectionStore.setHash(
props.name,
props.db,
props.keyPath,
row.key,
currentEditRow.value.key,
currentEditRow.value.value
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('save_value_succ'))
// update display value
// if (!isEmpty(updated)) {
// for (const key in updated) {
// props.value[key] = updated[key]
// }
// }
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
} finally {
currentEditRow.value.no = 0
}
},
onCancel: () => {
currentEditRow.value.no = 0
},
})
},
}
const columns = reactive([
{
key: 'no',
title: '#',
width: 80,
align: 'center',
titleAlign: 'center',
},
fieldColumn,
valueColumn,
actionColumn,
])
const tableData = computed(() => {
const data = []
let index = 0
for (const key in props.value) {
data.push({
no: ++index,
key,
value: props.value[key],
})
}
return data
})
const message = useMessage()
const onAddRow = () => {
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, types.HASH)
}
const filterValue = ref('')
const onFilterInput = (val) => {
switch (filterType.value) {
case filterOption[0].value:
valueColumn.filterOptionValue = null
fieldColumn.filterOptionValue = val
break
case filterOption[1].value:
fieldColumn.filterOptionValue = null
valueColumn.filterOptionValue = val
break
}
}
const onChangeFilterType = (type) => {
onFilterInput(filterValue.value)
}
const clearFilter = () => {
fieldColumn.filterOptionValue = null
valueColumn.filterOptionValue = null
}
const onUpdateFilter = (filters, sourceColumn) => {
switch (filterType.value) {
case filterOption[0].value:
fieldColumn.filterOptionValue = filters[sourceColumn.key]
break
case filterOption[1].value:
valueColumn.filterOptionValue = filters[sourceColumn.key]
break
}
}
</script>
<template>
<div class="content-wrapper flex-box-v">
<content-toolbar :db="props.db" :key-path="props.keyPath" :key-type="keyType" :server="props.name" :ttl="ttl" />
<div class="tb2 flex-box-h">
<div class="flex-box-h">
<n-input-group>
<n-select
v-model:value="filterType"
:consistent-menu-width="false"
:options="filterOption"
style="width: 120px"
@update:value="onChangeFilterType"
/>
<n-input
v-model:value="filterValue"
:placeholder="$t('search')"
clearable
@clear="clearFilter"
@update:value="onFilterInput"
/>
</n-input-group>
</div>
<div class="flex-item-expand"></div>
<n-button plain @click="onAddRow">
<template #icon>
<n-icon :component="AddLink" size="18" />
</template>
{{ $t('add_row') }}
</n-button>
</div>
<div class="fill-height flex-box-h" style="user-select: text">
<n-data-table
:key="(row) => row.no"
:columns="columns"
:data="tableData"
:single-column="true"
:single-line="false"
flex-height
max-height="100%"
size="small"
striped
virtual-scroll
@update:filters="onUpdateFilter"
/>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,212 @@
<script setup>
import { computed, h, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentToolbar from '../ContentToolbar.vue'
import AddLink from '../icons/AddLink.vue'
import { NButton, NCode, NIcon, NInput, useMessage } from 'naive-ui'
import { size } from 'lodash'
import { types, types as redisTypes } from '../../consts/support_redis_type.js'
import EditableTableColumn from '../EditableTableColumn.vue'
import useConnectionStore from '../../stores/connection.js'
import useDialogStore from '../../stores/dialog.js'
const i18n = useI18n()
const props = defineProps({
name: String,
db: Number,
keyPath: String,
ttl: {
type: Number,
default: -1,
},
value: Object,
})
const connectionStore = useConnectionStore()
const dialogStore = useDialogStore()
const keyType = redisTypes.LIST
const currentEditRow = ref({
no: 0,
value: null,
})
const valueColumn = reactive({
key: 'value',
title: i18n.t('value'),
align: 'center',
titleAlign: 'center',
filterOptionValue: null,
filter(value, row) {
return !!~row.value.indexOf(value.toString())
},
render: (row) => {
const isEdit = currentEditRow.value.no === row.no
if (isEdit) {
return h(NInput, {
value: currentEditRow.value.value,
type: 'textarea',
autosize: { minRow: 2, maxRows: 5 },
style: 'text-align: left;',
'onUpdate:value': (val) => {
currentEditRow.value.value = val
},
})
} else {
return h(NCode, { language: 'plaintext', wordWrap: true }, { default: () => row.value })
}
},
})
const actionColumn = {
key: 'action',
title: i18n.t('action'),
width: 100,
align: 'center',
titleAlign: 'center',
fixed: 'right',
render: (row) => {
return h(EditableTableColumn, {
editing: currentEditRow.value.no === row.no,
bindKey: '#' + row.no,
onEdit: () => {
currentEditRow.value.no = row.no
currentEditRow.value.value = row.value
},
onDelete: async () => {
try {
const { success, msg } = await connectionStore.removeListItem(
props.name,
props.db,
props.keyPath,
row.no - 1
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('delete_key_succ', { key: '#' + row.no }))
// update display value
// if (!isEmpty(removed)) {
// props.value.splice(removed[0], 1)
// }
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
}
},
onSave: async () => {
try {
const { success, msg } = await connectionStore.updateListItem(
props.name,
props.db,
props.keyPath,
currentEditRow.value.no - 1,
currentEditRow.value.value
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('save_value_succ'))
// update display value
// if (!isEmpty(updated)) {
// for (const key in updated) {
// props.value[key] = updated[key]
// }
// }
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
} finally {
currentEditRow.value.no = 0
}
},
onCancel: () => {
currentEditRow.value.no = 0
},
})
},
}
const columns = computed(() => {
return [
{
key: 'no',
title: '#',
width: 80,
align: 'center',
titleAlign: 'center',
},
valueColumn,
actionColumn,
]
})
const tableData = computed(() => {
const data = []
const len = size(props.value)
for (let i = 0; i < len; i++) {
data.push({
no: i + 1,
value: props.value[i],
})
}
return data
})
const message = useMessage()
const onAddValue = (value) => {
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, types.LIST)
}
const filterValue = ref('')
const onFilterInput = (val) => {
valueColumn.filterOptionValue = val
}
const clearFilter = () => {
valueColumn.filterOptionValue = null
}
const onUpdateFilter = (filters, sourceColumn) => {
valueColumn.filterOptionValue = filters[sourceColumn.key]
}
</script>
<template>
<div class="content-wrapper flex-box-v">
<content-toolbar :db="props.db" :key-path="props.keyPath" :key-type="keyType" :server="props.name" :ttl="ttl" />
<div class="tb2 flex-box-h">
<div class="flex-box-h">
<n-input
v-model:value="filterValue"
:placeholder="$t('search')"
clearable
@clear="clearFilter"
@update:value="onFilterInput"
/>
</div>
<div class="flex-item-expand"></div>
<n-button plain @click="onAddValue">
<template #icon>
<n-icon :component="AddLink" size="18" />
</template>
{{ $t('add_row') }}
</n-button>
</div>
<div class="fill-height flex-box-h" style="user-select: text">
<n-data-table
:key="(row) => row.no"
:columns="columns"
:data="tableData"
:single-column="true"
:single-line="false"
flex-height
max-height="100%"
size="small"
striped
virtual-scroll
@update:filters="onUpdateFilter"
/>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,208 @@
<script setup>
import { computed, h, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentToolbar from '../ContentToolbar.vue'
import AddLink from '../icons/AddLink.vue'
import { NButton, NCode, NIcon, NInput, useMessage } from 'naive-ui'
import { size } from 'lodash'
import useConnectionStore from '../../stores/connection.js'
import useDialogStore from '../../stores/dialog.js'
import { types, types as redisTypes } from '../../consts/support_redis_type.js'
import EditableTableColumn from '../EditableTableColumn.vue'
const i18n = useI18n()
const props = defineProps({
name: String,
db: Number,
keyPath: String,
ttl: {
type: Number,
default: -1,
},
value: Array,
})
const connectionStore = useConnectionStore()
const dialogStore = useDialogStore()
const keyType = redisTypes.LIST
const currentEditRow = ref({
no: 0,
value: null,
})
const valueColumn = reactive({
key: 'value',
title: i18n.t('value'),
align: 'center',
titleAlign: 'center',
filterOptionValue: null,
filter(value, row) {
return !!~row.value.indexOf(value.toString())
},
render: (row) => {
const isEdit = currentEditRow.value.no === row.no
if (isEdit) {
return h(NInput, {
value: currentEditRow.value.value,
type: 'textarea',
autosize: { minRow: 2, maxRows: 5 },
style: 'text-align: left;',
'onUpdate:value': (val) => {
currentEditRow.value.value = val
},
})
} else {
return h(NCode, { language: 'plaintext', wordWrap: true }, { default: () => row.value })
}
},
})
const actionColumn = {
key: 'action',
title: i18n.t('action'),
width: 100,
align: 'center',
titleAlign: 'center',
fixed: 'right',
render: (row) => {
return h(EditableTableColumn, {
editing: currentEditRow.value.no === row.no,
bindKey: row.value,
onEdit: () => {
currentEditRow.value.no = row.no
currentEditRow.value.key = row.key
currentEditRow.value.value = row.value
},
onDelete: async () => {
try {
const { success, msg } = await connectionStore.removeSetItem(
props.name,
props.db,
props.keyPath,
row.value
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('delete_key_succ', { key: row.value }))
// update display value
// props.value.splice(row.no - 1, 1)
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
}
},
onSave: async () => {
try {
const { success, msg } = await connectionStore.updateSetItem(
props.name,
props.db,
props.keyPath,
row.value,
currentEditRow.value.value
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('save_value_succ'))
// update display value
// props.value[row.no - 1] = currentEditRow.value.value
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
} finally {
currentEditRow.value.no = 0
}
},
onCancel: () => {
currentEditRow.value.no = 0
},
})
},
}
const columns = computed(() => {
return [
{
key: 'no',
title: '#',
width: 80,
align: 'center',
titleAlign: 'center',
},
valueColumn,
actionColumn,
]
})
const tableData = computed(() => {
const data = []
const len = size(props.value)
for (let i = 0; i < len; i++) {
data.push({
no: i + 1,
value: props.value[i],
})
}
return data
})
const message = useMessage()
const onAddValue = (value) => {
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, types.SET)
}
const filterValue = ref('')
const onFilterInput = (val) => {
valueColumn.filterOptionValue = val
}
const clearFilter = () => {
valueColumn.filterOptionValue = null
}
const onUpdateFilter = (filters, sourceColumn) => {
valueColumn.filterOptionValue = filters[sourceColumn.key]
}
</script>
<template>
<div class="content-wrapper flex-box-v">
<content-toolbar :db="props.db" :key-path="props.keyPath" :key-type="keyType" :server="props.name" :ttl="ttl" />
<div class="tb2 flex-box-h">
<div class="flex-box-h">
<n-input
v-model:value="filterValue"
:placeholder="$t('search')"
clearable
@clear="clearFilter"
@update:value="onFilterInput"
/>
</div>
<div class="flex-item-expand"></div>
<n-button plain @click="onAddValue">
<template #icon>
<n-icon :component="AddLink" size="18" />
</template>
{{ $t('add_row') }}
</n-button>
</div>
<div class="fill-height flex-box-h" style="user-select: text">
<n-data-table
:key="(row) => row.no"
:columns="columns"
:data="tableData"
:single-column="true"
:single-line="false"
flex-height
max-height="100%"
size="small"
striped
virtual-scroll
@update:filters="onUpdateFilter"
/>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,219 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentToolbar from '../ContentToolbar.vue'
import Copy from '../icons/Copy.vue'
import Save from '../icons/Save.vue'
import { useMessage } from 'naive-ui'
import { types } from '../../consts/value_view_type.js'
import Close from '../icons/Close.vue'
import Edit from '../icons/Edit.vue'
import { IsJson } from '../../utils/check_string_format.js'
import useConnectionStore from '../../stores/connection.js'
import { types as redisTypes } from '../../consts/support_redis_type.js'
import { ClipboardSetText } from '../../../wailsjs/runtime/runtime.js'
import { toLower } from 'lodash'
const props = defineProps({
name: String,
db: Number,
keyPath: String,
ttl: {
type: Number,
default: -1,
},
value: String,
})
const viewOption = [
{
value: types.PLAIN_TEXT,
label: types.PLAIN_TEXT,
},
{
value: types.JSON,
label: types.JSON,
},
{
value: types.BASE64_TO_TEXT,
label: types.BASE64_TO_TEXT,
},
{
value: types.BASE64_TO_JSON,
label: types.BASE64_TO_JSON,
},
]
const viewAs = ref(types.PLAIN_TEXT)
const jsonValue = computed(() => {
try {
const jsonObj = JSON.parse(props.value)
return JSON.stringify(jsonObj, null, 2)
} catch (e) {
return props.value
}
})
const autoDetectFormat = () => {
// auto check format when loaded
if (IsJson(jsonValue.value)) {
viewAs.value = types.JSON
} else {
viewAs.value = types.PLAIN_TEXT
}
}
onMounted(() => {
autoDetectFormat()
})
watch(
() => props.value,
(value) => {
autoDetectFormat()
}
)
const keyType = redisTypes.STRING
const viewValue = computed(() => {
switch (viewAs.value) {
case types.PLAIN_TEXT:
return props.value
case types.JSON:
return jsonValue.value
case types.BASE64_TO_TEXT:
return props.value
case types.BASE64_TO_JSON:
return props.value
default:
return props.value
}
})
const viewLanguage = computed(() => {
switch (viewAs.value) {
case types.PLAIN_TEXT:
case types.BASE64_TO_TEXT:
return 'plaintext'
case types.JSON:
case types.BASE64_TO_JSON:
return 'json'
default:
return 'plaintext'
}
})
const i18n = useI18n()
const message = useMessage()
/**
* Copy value
*/
const onCopyValue = () => {
ClipboardSetText(viewValue.value)
.then((succ) => {
if (succ) {
message.success(i18n.t('copy_succ'))
}
})
.catch((e) => {
message.error(e.message)
})
}
const editValue = ref('')
const inEdit = ref(false)
const onEditValue = () => {
editValue.value = viewValue.value
inEdit.value = true
}
const onCancelEdit = () => {
inEdit.value = false
}
/**
* Save value
*/
const connectionStore = useConnectionStore()
const saving = ref(false)
const onSaveValue = async () => {
saving.value = true
try {
const { success, msg } = await connectionStore.setKey(
props.name,
props.db,
props.keyPath,
toLower(props.keyType),
editValue.value,
-1
)
if (success) {
await connectionStore.loadKeyValue(props.name, props.db, props.keyPath)
message.success(i18n.t('save_value_succ'))
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
} finally {
inEdit.value = false
saving.value = false
}
}
</script>
<template>
<div class="content-wrapper flex-box-v">
<content-toolbar :db="props.db" :key-path="keyPath" :key-type="keyType" :server="props.name" :ttl="ttl" />
<div class="tb2 flex-box-h">
<n-text>{{ $t('view_as') }}</n-text>
<n-select v-model:value="viewAs" :options="viewOption" style="width: 200px" />
<div class="flex-item-expand"></div>
<n-button-group v-if="!inEdit">
<n-button @click="onCopyValue">
<template #icon>
<n-icon :component="Copy" size="18" />
</template>
{{ $t('copy_value') }}
</n-button>
<n-button plain @click="onEditValue">
<template #icon>
<n-icon :component="Edit" size="18" />
</template>
{{ $t('edit_value') }}
</n-button>
</n-button-group>
<n-button-group v-else>
<n-button :loading="saving" plain @click="onSaveValue">
<template #icon>
<n-icon :component="Save" size="18" />
</template>
{{ $t('save_update') }}
</n-button>
<n-button :loading="saving" plain @click="onCancelEdit">
<template #icon>
<n-icon :component="Close" size="18" />
</template>
{{ $t('cancel_update') }}
</n-button>
</n-button-group>
</div>
<div class="value-wrapper flex-item-expand flex-box-v">
<n-scrollbar v-if="!inEdit" class="flex-item-expand">
<n-code :code="viewValue" :language="viewLanguage" show-line-numbers word-wrap />
</n-scrollbar>
<n-input
v-else
v-model:value="editValue"
:disabled="saving"
:resizable="false"
class="flex-item-expand"
type="textarea"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.value-wrapper {
overflow: hidden;
}
</style>

View File

@ -0,0 +1,238 @@
<script setup>
import { computed, h, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentToolbar from '../ContentToolbar.vue'
import AddLink from '../icons/AddLink.vue'
import { NButton, NCode, NIcon, NInput, NInputNumber, useMessage } from 'naive-ui'
import { types, types as redisTypes } from '../../consts/support_redis_type.js'
import EditableTableColumn from '../EditableTableColumn.vue'
import useConnectionStore from '../../stores/connection.js'
import { isEmpty } from 'lodash'
import useDialogStore from '../../stores/dialog.js'
const i18n = useI18n()
const props = defineProps({
name: String,
db: Number,
keyPath: String,
ttl: {
type: Number,
default: -1,
},
value: Object,
})
const connectionStore = useConnectionStore()
const dialogStore = useDialogStore()
const keyType = redisTypes.ZSET
const currentEditRow = ref({
no: 0,
score: 0,
value: null,
})
const scoreColumn = reactive({
key: 'score',
title: i18n.t('score'),
align: 'center',
titleAlign: 'center',
resizable: true,
render: (row) => {
const isEdit = currentEditRow.value.no === row.no
if (isEdit) {
return h(NInputNumber, {
value: currentEditRow.value.score,
'onUpdate:value': (val) => {
currentEditRow.value.score = val
},
})
} else {
return row.score
}
},
})
const valueColumn = reactive({
key: 'value',
title: i18n.t('value'),
align: 'center',
titleAlign: 'center',
resizable: true,
filterOptionValue: null,
filter(value, row) {
return !!~row.value.indexOf(value.toString())
},
// sorter: (row1, row2) => row1.value - row2.value,
// ellipsis: {
// tooltip: true
// },
render: (row) => {
const isEdit = currentEditRow.value.no === row.no
if (isEdit) {
return h(NInput, {
value: currentEditRow.value.value,
type: 'textarea',
autosize: { minRow: 2, maxRows: 5 },
style: 'text-align: left;',
'onUpdate:value': (val) => {
currentEditRow.value.value = val
},
})
} else {
return h(NCode, { language: 'plaintext', wordWrap: true }, { default: () => row.value })
}
},
})
const actionColumn = {
key: 'action',
title: i18n.t('action'),
width: 100,
align: 'center',
titleAlign: 'center',
fixed: 'right',
render: (row) => {
return h(EditableTableColumn, {
editing: currentEditRow.value.no === row.no,
bindKey: row.value,
onEdit: () => {
currentEditRow.value.no = row.no
currentEditRow.value.value = row.value
currentEditRow.value.score = row.score
},
onDelete: async () => {
try {
const { success, msg } = await connectionStore.removeZSetItem(
props.name,
props.db,
props.keyPath,
row.value
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('delete_key_succ', { key: row.value }))
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
}
},
onSave: async () => {
try {
const newValue = currentEditRow.value.value
if (isEmpty(newValue)) {
message.error(i18n.t('spec_field_required', { key: i18n.t('value') }))
return
}
const { success, msg } = await connectionStore.updateZSetItem(
props.name,
props.db,
props.keyPath,
row.value,
newValue,
currentEditRow.value.score
)
if (success) {
connectionStore.loadKeyValue(props.name, props.db, props.keyPath).then((r) => {})
message.success(i18n.t('save_value_succ'))
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
} finally {
currentEditRow.value.no = 0
}
},
onCancel: () => {
currentEditRow.value.no = 0
},
})
},
}
const columns = reactive([
{
key: 'no',
title: '#',
width: 80,
align: 'center',
titleAlign: 'center',
},
valueColumn,
scoreColumn,
actionColumn,
])
const tableData = computed(() => {
const data = []
let index = 0
for (const elem of props.value) {
data.push({
no: ++index,
value: elem.value,
score: elem.score,
})
}
return data
})
const message = useMessage()
const onAddRow = () => {
dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, types.ZSET)
}
const filterValue = ref('')
const onFilterInput = (val) => {
valueColumn.filterOptionValue = val
}
const onChangeFilterType = (type) => {
onFilterInput(filterValue.value)
}
const clearFilter = () => {
valueColumn.filterOptionValue = null
}
const onUpdateFilter = (filters, sourceColumn) => {
valueColumn.filterOptionValue = filters[sourceColumn.key]
}
</script>
<template>
<div class="content-wrapper flex-box-v">
<content-toolbar :db="props.db" :key-path="props.keyPath" :key-type="keyType" :server="props.name" :ttl="ttl" />
<div class="tb2 flex-box-h">
<div class="flex-box-h">
<n-input
v-model:value="filterValue"
:placeholder="$t('search')"
clearable
@clear="clearFilter"
@update:value="onFilterInput"
/>
</div>
<div class="flex-item-expand"></div>
<n-button plain @click="onAddRow">
<template #icon>
<n-icon :component="AddLink" size="18" />
</template>
{{ $t('add_row') }}
</n-button>
</div>
<div class="fill-height flex-box-h" style="user-select: text">
<n-data-table
:key="(row) => row.no"
:columns="columns"
:data="tableData"
:single-column="true"
:single-line="false"
flex-height
max-height="100%"
size="small"
striped
virtual-scroll
@update:filters="onUpdateFilter"
/>
</div>
</div>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,203 @@
<script setup>
import { computed, reactive, watch } from 'vue'
import { types } from '../../consts/support_redis_type'
import useDialog from '../../stores/dialog'
import NewStringValue from '../new_value/NewStringValue.vue'
import NewSetValue from '../new_value/NewSetValue.vue'
import useConnectionStore from '../../stores/connection.js'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import AddListValue from '../new_value/AddListValue.vue'
import AddHashValue from '../new_value/AddHashValue.vue'
import AddZSetValue from '../new_value/AddZSetValue.vue'
const i18n = useI18n()
const newForm = reactive({
server: '',
db: 0,
key: '',
type: '',
opType: 0,
value: null,
reload: true,
})
const formLabelWidth = '60px'
const addValueComponent = {
[types.STRING]: NewStringValue,
[types.HASH]: AddHashValue,
[types.LIST]: AddListValue,
[types.SET]: NewSetValue,
[types.ZSET]: AddZSetValue,
}
const defaultValue = {
[types.STRING]: '',
[types.HASH]: [],
[types.LIST]: [],
[types.SET]: [],
[types.ZSET]: [],
}
/**
* dialog title
* @type {ComputedRef<string>}
*/
const title = computed(() => {
switch (newForm.type) {
case types.LIST:
return i18n.t('new_item')
case types.HASH:
return i18n.t('new_field')
case types.SET:
return i18n.t('new_field')
case types.ZSET:
return i18n.t('new_field')
}
return ''
})
const dialogStore = useDialog()
watch(
() => dialogStore.addFieldsDialogVisible,
(visible) => {
if (visible) {
const { server, db, key, type } = dialogStore.addFieldParam
newForm.server = server
newForm.db = db
newForm.key = key
newForm.type = type
newForm.opType = 0
newForm.value = null
}
}
)
const connectionStore = useConnectionStore()
const message = useMessage()
const onAdd = async () => {
try {
const { server, db, key, type } = newForm
let { value } = newForm
if (value == null) {
value = defaultValue[type]
}
switch (type) {
case types.LIST:
{
let data
if (newForm.opType === 1) {
data = await connectionStore.prependListItem(server, db, key, value)
} else {
data = await connectionStore.appendListItem(server, db, key, value)
}
const { success, msg } = data
if (success) {
if (newForm.reload) {
connectionStore.loadKeyValue(server, db, key).then(() => {})
}
message.success(i18n.t('handle_succ'))
} else {
message.error(msg)
}
}
break
case types.HASH:
{
const { success, msg } = await connectionStore.addHashField(server, db, key, newForm.opType, value)
if (success) {
if (newForm.reload) {
connectionStore.loadKeyValue(server, db, key).then(() => {})
}
message.success(i18n.t('handle_succ'))
} else {
message.error(msg)
}
}
break
case types.SET:
{
const { success, msg } = await connectionStore.addSetItem(server, db, key, value)
if (success) {
if (newForm.reload) {
connectionStore.loadKeyValue(server, db, key).then(() => {})
}
message.success(i18n.t('handle_succ'))
} else {
message.error(msg)
}
}
break
case types.ZSET:
{
const { success, msg } = await connectionStore.addZSetItem(server, db, key, newForm.opType, value)
if (success) {
if (newForm.reload) {
connectionStore.loadKeyValue(server, db, key).then(() => {})
}
message.success(i18n.t('handle_succ'))
} else {
message.error(msg)
}
}
break
}
dialogStore.closeAddFieldsDialog()
} catch (e) {
message.error(e.message)
}
}
const onClose = () => {
dialogStore.closeAddFieldsDialog()
}
</script>
<template>
<n-modal
v-model:show="dialogStore.addFieldsDialogVisible"
:closable="false"
:close-on-esc="false"
:mask-closable="false"
:negative-button-props="{ size: 'medium' }"
:negative-text="$t('cancel')"
:positive-button-props="{ size: 'medium' }"
:positive-text="$t('confirm')"
:show-icon="false"
:title="title"
preset="dialog"
style="width: 600px"
transform-origin="center"
@positive-click="onAdd"
@negative-click="onClose"
>
<n-scrollbar style="max-height: 500px">
<n-form
:label-width="formLabelWidth"
:model="newForm"
:show-require-mark="false"
label-align="right"
label-placement="left"
style="padding-right: 15px"
>
<n-form-item :label="$t('key')" path="key" required>
<n-input v-model:value="newForm.key" placeholder="" readonly />
</n-form-item>
<component
:is="addValueComponent[newForm.type]"
v-model:type="newForm.opType"
v-model:value="newForm.value"
/>
<n-form-item label=" " path="key" required>
<n-checkbox v-model:checked="newForm.reload">
{{ $t('reload_when_succ') }}
</n-checkbox>
</n-form-item>
</n-form>
</n-scrollbar>
</n-modal>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,252 @@
<script setup>
import { isEmpty } from 'lodash'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { SaveConnection, TestConnection } from '../../../wailsjs/go/services/connectionService.js'
import useDialog from '../../stores/dialog'
import { useMessage } from 'naive-ui'
import Close from '../icons/Close.vue'
const generalFormValue = {
group: '',
name: '',
addr: '127.0.0.1',
port: 6379,
username: '',
password: '',
defaultFilter: '*',
keySeparator: ':',
connTimeout: 60,
execTimeout: 60,
markColor: '',
}
const message = useMessage()
const i18n = useI18n()
const generalForm = ref(Object.assign({}, generalFormValue))
const generalFormRules = () => {
const requiredMsg = i18n.t('field_required')
return {
name: { required: true, message: requiredMsg, trigger: 'input' },
addr: { required: true, message: requiredMsg, trigger: 'input' },
defaultFilter: { required: true, message: requiredMsg, trigger: 'input' },
keySeparator: { required: true, message: requiredMsg, trigger: 'input' },
}
}
const tab = ref('general')
const testing = ref(false)
const showTestResult = ref(false)
const testResult = ref('')
const showTestConnSuccResult = computed(() => {
return isEmpty(testResult.value) && showTestResult.value === true
})
const showTestConnFailResult = computed(() => {
return !isEmpty(testResult.value) && showTestResult.value === true
})
const formLabelWidth = computed(() => {
// Compatible with long english word
if (tab.value === 'advanced' && i18n.locale.value === 'en') {
return '140px'
}
return '80px'
})
const predefineColors = ref(['', '#FE5959', '#FEC230', '#FEF27F', '#6CEFAF', '#46C3FC', '#B388FC', '#B0BEC5'])
const dialogStore = useDialog()
const generalFormRef = ref(null)
const advanceFormRef = ref(null)
const onCreateConnection = async () => {
// Validate general form
await generalFormRef.value?.validate((err) => {
nextTick(() => (tab.value = 'general'))
})
// Validate advance form
await advanceFormRef.value?.validate((err) => {
nextTick(() => (tab.value = 'advanced'))
})
// Store new connection
const { success, msg } = await SaveConnection(generalForm.value, false)
if (!success) {
message.error(msg)
return
}
message.success(i18n.t('new_conn_succ'))
dialogStore.closeNewDialog()
}
const resetForm = () => {
generalForm.value = generalFormValue
generalFormRef.value?.restoreValidation()
showTestResult.value = false
testResult.value = ''
}
watch(
() => dialogStore.newDialogVisible,
(visible) => {}
)
const onTestConnection = async () => {
testResult.value = ''
testing.value = true
let result = ''
try {
const { addr, port, username, password } = generalForm.value
const { success = false, msg } = await TestConnection(addr, port, username, password)
if (!success) {
result = msg
}
} catch (e) {
result = e.message
} finally {
testing.value = false
showTestResult.value = true
}
if (!isEmpty(result)) {
testResult.value = result
} else {
testResult.value = ''
}
}
const onClose = () => {
dialogStore.closeNewDialog()
}
</script>
<template>
<n-modal
v-model:show="dialogStore.newDialogVisible"
:closable="false"
:close-on-esc="false"
:mask-closable="false"
:on-after-leave="resetForm"
:show-icon="false"
:title="$t('new_conn_title')"
preset="dialog"
transform-origin="center"
>
<n-tabs v-model:value="tab">
<n-tab-pane :tab="$t('general')" display-directive="show" name="general">
<n-form
ref="generalFormRef"
:label-width="formLabelWidth"
:model="generalForm"
:rules="generalFormRules()"
:show-require-mark="false"
label-align="right"
label-placement="left"
>
<n-form-item :label="$t('conn_name')" path="name" required>
<n-input v-model:value="generalForm.name" :placeholder="$t('conn_name_tip')" />
</n-form-item>
<n-form-item :label="$t('conn_addr')" path="addr" required>
<n-input v-model:value="generalForm.addr" :placeholder="$t('conn_addr_tip')" />
<n-text style="width: 40px; text-align: center">:</n-text>
<n-input-number v-model:value="generalForm.port" :max="65535" :min="1" style="width: 200px" />
</n-form-item>
<n-form-item :label="$t('conn_pwd')" path="password">
<n-input
v-model:value="generalForm.password"
:placeholder="$t('conn_pwd_tip')"
show-password-on="click"
type="password"
/>
</n-form-item>
<n-form-item :label="$t('conn_usr')" path="username">
<n-input v-model="generalForm.username" :placeholder="$t('conn_usr_tip')" />
</n-form-item>
</n-form>
</n-tab-pane>
<n-tab-pane :tab="$t('advanced')" display-directive="show" name="advanced">
<n-form
ref="advanceFormRef"
:label-width="formLabelWidth"
:model="generalForm"
:rules="generalFormRules()"
:show-require-mark="false"
label-align="right"
label-placement="left"
>
<n-form-item :label="$t('conn_advn_filter')" path="defaultFilter">
<n-input v-model:value="generalForm.defaultFilter" :placeholder="$t('conn_advn_filter_tip')" />
</n-form-item>
<n-form-item :label="$t('conn_advn_separator')" path="keySeparator">
<n-input
v-model:value="generalForm.keySeparator"
:placeholder="$t('conn_advn_separator_tip')"
/>
</n-form-item>
<n-form-item :label="$t('conn_advn_conn_timeout')" path="connTimeout">
<n-input-number v-model:value="generalForm.connTimeout" :max="999999" :min="1">
<template #suffix>
{{ $t('second') }}
</template>
</n-input-number>
</n-form-item>
<n-form-item :label="$t('conn_advn_exec_timeout')" path="execTimeout">
<n-input-number v-model:value="generalForm.execTimeout" :max="999999" :min="1">
<template #suffix>
{{ $t('second') }}
</template>
</n-input-number>
</n-form-item>
<n-form-item :label="$t('conn_advn_mark_color')" path="markColor">
<div
v-for="color in predefineColors"
:key="color"
:class="{
'color-preset-item_selected': generalForm.markColor === color,
}"
:style="{ backgroundColor: color }"
class="color-preset-item"
@click="generalForm.markColor = color"
>
<n-icon v-if="color === ''" :component="Close" size="24" />
</div>
</n-form-item>
</n-form>
</n-tab-pane>
</n-tabs>
<!-- test result alert-->
<n-alert v-if="showTestConnSuccResult" title="" type="success">
{{ $t('conn_test_succ') }}
</n-alert>
<n-alert v-if="showTestConnFailResult" title="" type="error">
{{ $t('conn_test_fail') }}: {{ testResult }}
</n-alert>
<template #action>
<div class="flex-item-expand">
<n-button :loading="testing" @click="onTestConnection">{{ $t('conn_test') }}</n-button>
</div>
<div class="flex-item n-dialog__action">
<n-button @click="onClose">{{ $t('cancel') }}</n-button>
<n-button type="primary" @click="onCreateConnection">{{ $t('confirm') }}</n-button>
</div>
</template>
</n-modal>
</template>
<style lang="scss" scoped>
.color-preset-item {
width: 24px;
height: 24px;
margin-right: 2px;
border: white 3px solid;
cursor: pointer;
&_selected,
&:hover {
border-color: #cdd0d6;
}
}
</style>

View File

@ -0,0 +1,147 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { types } from '../../consts/support_redis_type'
import useDialog from '../../stores/dialog'
import { isEmpty } from 'lodash'
import NewStringValue from '../new_value/NewStringValue.vue'
import NewHashValue from '../new_value/NewHashValue.vue'
import NewListValue from '../new_value/NewListValue.vue'
import NewZSetValue from '../new_value/NewZSetValue.vue'
import NewSetValue from '../new_value/NewSetValue.vue'
import useConnectionStore from '../../stores/connection.js'
import { useI18n } from 'vue-i18n'
const i18n = useI18n()
const newForm = reactive({
server: '',
db: 0,
key: '',
type: '',
ttl: -1,
value: null,
})
const formRules = computed(() => {
const requiredMsg = i18n.t('field_required')
return {
key: { required: true, message: requiredMsg, trigger: 'input' },
type: { required: true, message: requiredMsg, trigger: 'input' },
ttl: { required: true, message: requiredMsg, trigger: 'input' },
}
})
const newFormRef = ref(null)
const formLabelWidth = '60px'
const options = computed(() => {
return Object.keys(types).map((t) => ({
value: t,
label: t,
}))
})
const addValueComponent = {
[types.STRING]: NewStringValue,
[types.HASH]: NewHashValue,
[types.LIST]: NewListValue,
[types.SET]: NewSetValue,
[types.ZSET]: NewZSetValue,
}
const defaultValue = {
[types.STRING]: '',
[types.HASH]: [],
[types.LIST]: [],
[types.SET]: [],
[types.ZSET]: [],
}
const dialogStore = useDialog()
watch(
() => dialogStore.newKeyDialogVisible,
(visible) => {
if (visible) {
const { prefix, server, db } = dialogStore.newKeyParam
newForm.server = server
newForm.db = db
newForm.key = isEmpty(prefix) ? '' : prefix
newForm.type = options.value[0].value
newForm.ttl = -1
newForm.value = null
}
}
)
const connectionStore = useConnectionStore()
const onAdd = async () => {
await newFormRef.value?.validate()
try {
const { server, db, key, type, ttl } = newForm
let { value } = newForm
if (value == null) {
value = defaultValue[type]
}
await connectionStore.setKey(server, db, key, type, value, ttl)
dialogStore.closeNewKeyDialog()
} catch (e) {}
}
const onClose = () => {
dialogStore.closeNewKeyDialog()
}
</script>
<template>
<n-modal
v-model:show="dialogStore.newKeyDialogVisible"
:closable="false"
:close-on-esc="false"
:mask-closable="false"
:negative-button-props="{ size: 'medium' }"
:negative-text="$t('cancel')"
:positive-button-props="{ size: 'medium' }"
:positive-text="$t('confirm')"
:show-icon="false"
:title="$t('new_key')"
preset="dialog"
style="width: 600px"
transform-origin="center"
@positive-click="onAdd"
@negative-click="onClose"
>
<n-scrollbar style="max-height: 500px">
<n-form
ref="newFormRef"
:label-width="formLabelWidth"
:model="newForm"
:rules="formRules"
:show-require-mark="false"
label-align="right"
label-placement="left"
style="padding-right: 15px"
>
<n-form-item :label="$t('key')" path="key" required>
<n-input v-model:value="newForm.key" placeholder="" />
</n-form-item>
<n-form-item :label="$t('type')" path="type" required>
<n-select v-model:value="newForm.type" :options="options" />
</n-form-item>
<n-form-item :label="$t('ttl')" required>
<n-input-group>
<n-input-number
v-model:value="newForm.ttl"
:max="Number.MAX_SAFE_INTEGER"
:min="-1"
placeholder="TTL"
>
<template #suffix>
{{ $t('second') }}
</template>
</n-input-number>
<n-button secondary type="primary" @click="newForm.ttl = -1">{{ $t('persist_key') }}</n-button>
</n-input-group>
</n-form-item>
<component :is="addValueComponent[newForm.type]" v-model:value="newForm.value" />
<!-- TODO: Add import from txt file option -->
</n-form>
</n-scrollbar>
</n-modal>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,189 @@
<script setup>
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { GetPreferences, RestoreDefault, SetPreferencesN } from '../../../wailsjs/go/storage/PreferencesStorage.js'
import { lang } from '../../langs/index'
import useDialog from '../../stores/dialog'
const langOption = Object.entries(lang).map(([key, value]) => ({
value: key,
label: `${value['lang_name']}`,
}))
const fontOption = [
{
label: 'JetBrains Mono',
value: 'JetBrains Mono',
},
]
const generalForm = reactive({
language: langOption[0].value,
font: '',
fontSize: 14,
useSystemProxy: false,
useSystemProxyHttp: false,
checkUpdate: false,
})
const editorForm = reactive({
font: '',
fontSize: 14,
})
const prevPreferences = ref({})
const tab = ref('general')
const formLabelWidth = '80px'
const dialogStore = useDialog()
const i18n = useI18n()
const applyPreferences = (pf) => {
const { general = {}, editor = {} } = pf
generalForm.language = general['language']
generalForm.font = general['font']
generalForm.fontSize = general['font_size'] || 14
generalForm.useSystemProxy = general['use_system_proxy'] === true
generalForm.useSystemProxyHttp = general['use_system_proxy_http'] === true
generalForm.checkUpdate = general['check_update'] === true
editorForm.font = editor['font']
editorForm.fontSize = editor['font_size'] || 14
}
watch(
() => dialogStore.preferencesDialogVisible,
(visible) => {
if (visible) {
GetPreferences()
.then((pf) => {
// load preferences from local
applyPreferences(pf)
prevPreferences.value = pf
})
.catch((e) => {
console.log(e)
})
}
}
)
const onSavePreferences = async () => {
const pf = {
'general.language': generalForm.language,
'general.font': generalForm.font,
'general.font_size': generalForm.fontSize,
'general.use_system_proxy': generalForm.useSystemProxy,
'general.use_system_proxy_http': generalForm.useSystemProxyHttp,
'general.check_update': generalForm.checkUpdate,
'editor.font': editorForm.font,
'editor.font_size': editorForm.fontSize,
}
await SetPreferencesN(pf)
dialogStore.closePreferencesDialog()
}
// Watch language and dynamically switch
watch(
() => generalForm.language,
(lang) => (i18n.locale.value = lang)
)
watch(
() => generalForm.font,
(font) => {}
)
const onRestoreDefaults = async () => {
const pf = await RestoreDefault()
applyPreferences(pf)
}
const onClose = () => {
dialogStore.closePreferencesDialog()
// restore to old preferences
applyPreferences(prevPreferences.value)
}
</script>
<template>
<n-modal
v-model:show="dialogStore.preferencesDialogVisible"
:closable="false"
:close-on-esc="false"
:mask-closable="false"
:show-icon="false"
:title="$t('preferences')"
preset="dialog"
transform-origin="center"
>
<n-tabs v-model:value="tab">
<n-tab-pane :tab="$t('general')" display-directive="show" name="general">
<n-form
:label-width="formLabelWidth"
:model="generalForm"
:show-require-mark="false"
label-align="right"
label-placement="left"
>
<n-form-item :label="$t('language')" required>
<n-select v-model:value="generalForm.language" :options="langOption" filterable />
</n-form-item>
<n-form-item :label="$t('font')" required>
<n-select v-model:value="generalForm.font" :options="fontOption" filterable />
</n-form-item>
<n-form-item :label="$t('font_size')">
<n-input-number v-model:value="generalForm.fontSize" :max="65535" :min="1" />
</n-form-item>
<n-form-item :label="$t('proxy')">
<n-space>
<n-checkbox v-model:checked="generalForm.useSystemProxy">
{{ $t('use_system_proxy') }}
</n-checkbox>
<n-checkbox v-model:checked="generalForm.useSystemProxyHttp">
{{ $t('use_system_proxy_http') }}
</n-checkbox>
</n-space>
</n-form-item>
<n-form-item :label="$t('update')">
<n-checkbox v-model:checked="generalForm.checkUpdate"
>{{ $t('auto_check_update') }}
</n-checkbox>
</n-form-item>
</n-form>
</n-tab-pane>
<n-tab-pane :tab="$t('editor')" display-directive="show" name="editor">
<n-form
:label-width="formLabelWidth"
:model="editorForm"
:show-require-mark="false"
label-align="right"
label-placement="left"
>
<n-form-item :label="$t('font')" :label-width="formLabelWidth" required>
<n-select v-model="editorForm.font" :options="fontOption" filterable />
</n-form-item>
<n-form-item :label="$t('font_size')" :label-width="formLabelWidth">
<n-input-number v-model="editorForm.fontSize" :max="65535" :min="1" />
</n-form-item>
</n-form>
</n-tab-pane>
</n-tabs>
<template #action>
<div class="flex-item-expand">
<n-button @click="onRestoreDefaults">{{ $t('restore_defaults') }}</n-button>
</div>
<div class="flex-item n-dialog__action">
<n-button @click="onClose">{{ $t('cancel') }}</n-button>
<n-button type="primary" @click="onSavePreferences">{{ $t('save') }}</n-button>
</div>
</template>
</n-modal>
</template>
<style lang="scss" scoped>
.inline-form-item {
padding-right: 10px;
}
</style>

View File

@ -0,0 +1,84 @@
<script setup>
import { reactive, watch } from 'vue'
import useDialog from '../../stores/dialog'
import useConnectionStore from '../../stores/connection.js'
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
const renameForm = reactive({
server: '',
db: 0,
key: '',
newKey: '',
})
const dialogStore = useDialog()
const connectionStore = useConnectionStore()
watch(
() => dialogStore.renameDialogVisible,
(visible) => {
if (visible) {
const { server, db, key } = dialogStore.renameKeyParam
renameForm.server = server
renameForm.db = db
renameForm.key = key
renameForm.newKey = key
}
}
)
const i18n = useI18n()
const message = useMessage()
const onRename = async () => {
try {
const { server, db, key, newKey } = renameForm
const { success, msg } = await connectionStore.renameKey(server, db, key, newKey)
if (success) {
await connectionStore.loadKeyValue(server, db, newKey)
message.success(i18n.t('handle_succ'))
} else {
message.error(msg)
}
} catch (e) {
message.error(e.message)
}
dialogStore.closeRenameKeyDialog()
}
const onClose = () => {
dialogStore.closeRenameKeyDialog()
}
</script>
<template>
<n-modal
v-model:show="dialogStore.renameDialogVisible"
:closable="false"
:close-on-esc="false"
:mask-closable="false"
:negative-button-props="{ size: 'medium' }"
:negative-text="$t('cancel')"
:positive-button-props="{ size: 'medium' }"
:positive-text="$t('confirm')"
:show-icon="false"
:title="$t('rename_key')"
preset="dialog"
transform-origin="center"
@positive-click="onRename"
@negative-click="onClose"
>
<n-form
:model="renameForm"
:show-require-mark="false"
label-align="left"
label-placement="left"
label-width="auto"
>
<n-form-item :label="$t('new_key_name')" required>
<n-input v-model:value="renameForm.newKey" />
</n-form-item>
</n-form>
</n-modal>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,115 @@
<script setup>
import { reactive, ref, watch } from 'vue'
import useDialog from '../../stores/dialog'
import useTabStore from '../../stores/tab.js'
import useConnectionStore from '../../stores/connection.js'
import { useMessage } from 'naive-ui'
const ttlForm = reactive({
ttl: -1,
})
const formLabelWidth = '80px'
const dialogStore = useDialog()
const connectionStore = useConnectionStore()
const tabStore = useTabStore()
const currentServer = ref('')
const currentKey = ref('')
const currentDB = ref(0)
watch(
() => dialogStore.ttlDialogVisible,
(visible) => {
if (visible) {
// get ttl from current tab
const tab = tabStore.currentTab
if (tab != null) {
ttlForm.key = tab.key
if (tab.ttl < 0) {
// forever
} else {
ttlForm.ttl = tab.ttl
}
currentServer.value = tab.name
currentDB.value = tab.db
currentKey.value = tab.key
}
}
}
)
const onClose = () => {
dialogStore.closeTTLDialog()
}
const message = useMessage()
const onConfirm = async () => {
try {
const tab = tabStore.currentTab
if (tab == null) {
return
}
const success = await connectionStore.setTTL(tab.name, tab.db, tab.key, ttlForm.ttl)
if (success) {
tabStore.updateTTL({
server: currentServer.value,
db: currentDB.value,
key: currentKey.value,
ttl: ttlForm.ttl,
})
}
} catch (e) {
} finally {
dialogStore.closeTTLDialog()
}
}
</script>
<template>
<n-modal
v-model:show="dialogStore.ttlDialogVisible"
:closable="false"
:close-on-esc="false"
:mask-closable="false"
:show-icon="false"
:title="$t('set_ttl')"
preset="dialog"
transform-origin="center"
>
<n-form
:label-width="formLabelWidth"
:model="ttlForm"
:show-require-mark="false"
label-align="right"
label-placement="left"
>
<n-form-item :label="$t('key')">
<n-input :value="currentKey" readonly />
</n-form-item>
<n-form-item :label="$t('ttl')" required>
<n-input-number
v-model:value="ttlForm.ttl"
:max="Number.MAX_SAFE_INTEGER"
:min="-1"
style="width: 100%"
>
<template #suffix>
{{ $t('second') }}
</template>
</n-input-number>
</n-form-item>
</n-form>
<template #action>
<div class="flex-item-expand">
<n-button @click="ttlForm.ttl = -1">{{ $t('persist_key') }}</n-button>
</div>
<div class="flex-item n-dialog__action">
<n-button @click="onClose">{{ $t('cancel') }}</n-button>
<n-button type="primary" @click="onConfirm">{{ $t('save') }}</n-button>
</div>
</template>
</n-modal>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,36 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M24 16V32"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M16 24L32 24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,24 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M5 8C5 6.89543 5.89543 6 7 6H19L24 12H41C42.1046 12 43 12.8954 43 14V40C43 41.1046 42.1046 42 41 42H7C5.89543 42 5 41.1046 5 40V8Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M18 27H30" stroke="currentColor" stroke-linecap="round" />
<path :stroke-width="props.strokeWidth" d="M24 21L24 33" stroke="currentColor" stroke-linecap="round" />
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,57 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M8 28H24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M8 37H24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M8 19H40"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M8 10H40"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M30 33H40"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M35 28L35 38"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,36 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="strokeWidth"
d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M29.6567 18.3432L18.343 29.6569"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M18.3433 18.3432L29.657 29.6569"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,29 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M24 4L18 10H10V18L4 24L10 30V38H18L24 44L30 38H38V30L44 24L38 18V10H30L24 4Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M24 30C27.3137 30 30 27.3137 30 24C30 20.6863 27.3137 18 24 18C20.6863 18 18 20.6863 18 24C18 27.3137 20.6863 30 24 30Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,29 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" height="24" viewBox="0 0 48 48" width="24" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="strokeWidth"
d="M30 19H20C15.5817 19 12 22.5817 12 27C12 31.4183 15.5817 35 20 35H36C40.4183 35 44 31.4183 44 27C44 24.9711 43.2447 23.1186 42 21.7084"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M6 24.2916C4.75527 22.8814 4 21.0289 4 19C4 14.5817 7.58172 11 12 11H28C32.4183 11 36 14.5817 36 19C36 23.4183 32.4183 27 28 27H18"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,39 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M13 38H41V16H30V4H13V38Z"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M30 4L41 16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M7 20V44H28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M19 20H23" stroke="currentColor" stroke-linecap="round" />
<path :stroke-width="props.strokeWidth" d="M19 28H31" stroke="currentColor" stroke-linecap="round" />
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,59 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M12 9.92704V7C12 5.34315 13.3431 4 15 4H41C42.6569 4 44 5.34315 44 7V33C44 34.6569 42.6569 36 41 36H38.0174"
stroke="currentColor"
/>
<rect
:stroke-width="props.strokeWidth"
fill="none"
height="34"
rx="3"
stroke="currentColor"
stroke-linejoin="round"
width="34"
x="4"
y="10"
/>
<path
:stroke-width="props.strokeWidth"
d="M18.4394 23.1101L23.7319 17.6006C25.1835 16.1489 27.5691 16.1809 29.0602 17.672C30.5513 19.1631 30.5833 21.5487 29.1316 23.0003L27.2215 25.0231"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M13.4661 28.7472C12.9558 29.2575 11.9006 30.2765 11.9006 30.2765C10.4489 31.7281 10.4095 34.3155 11.9006 35.8066C13.3917 37.2977 15.7772 37.3296 17.2289 35.878L22.3931 31.1896"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M18.6626 28.3284C17.97 27.6358 17.5922 26.7502 17.5317 25.8548C17.4619 24.8226 17.8138 23.7774 18.5912 23.0001"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M22.3213 25.8613C23.8124 27.3524 23.8444 29.738 22.3927 31.1896"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,34 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M15 12L16.2 5H31.8L33 12"
stroke="currentColor"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M6 12H42" stroke="currentColor" stroke-linecap="round" />
<path
:stroke-width="props.strokeWidth"
clip-rule="evenodd"
d="M37 12L35 43H13L11 12H37Z"
fill="none"
fill-rule="evenodd"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M20 22V34" stroke="currentColor" stroke-linecap="round" />
<path :stroke-width="props.strokeWidth" d="M28 22V34" stroke="currentColor" stroke-linecap="round" />
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,29 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M7 42H43"
stroke="currentColorr"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M11 26.7199V34H18.3172L39 13.3081L31.6951 6L11 26.7199Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,13 @@
<script setup></script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
d="M6 9L20.4 25.8178V38.4444L27.6 42V25.8178L42 9H6Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="3"
/>
</svg>
</template>

View File

@ -0,0 +1,31 @@
<script setup>
const props = defineProps({
fillColor: {
type: String,
default: '#f2c55c',
},
})
</script>
<template>
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path
:fill="props.fillColor"
d="M218.1 167.17c0 13 0 25.6 4.1 37.4c-43.1 50.6-156.9 184.3-167.5 194.5a20.17 20.17 0 0 0-6.7 15c0 8.5 5.2 16.7 9.6 21.3c6.6 6.9 34.8 33 40 28c15.4-15 18.5-19 24.8-25.2c9.5-9.3-1-28.3 2.3-36s6.8-9.2 12.5-10.4s15.8 2.9 23.7 3c8.3.1 12.8-3.4 19-9.2c5-4.6 8.6-8.9 8.7-15.6c.2-9-12.8-20.9-3.1-30.4s23.7 6.2 34 5s22.8-15.5 24.1-21.6s-11.7-21.8-9.7-30.7c.7-3 6.8-10 11.4-11s25 6.9 29.6 5.9c5.6-1.2 12.1-7.1 17.4-10.4c15.5 6.7 29.6 9.4 47.7 9.4c68.5 0 124-53.4 124-119.2S408.5 48 340 48s-121.9 53.37-121.9 119.17zM400 144a32 32 0 1 1-32-32a32 32 0 0 1 32 32z"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="32"
></path>
</svg>
<!-- <svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">-->
<!-- <path-->
<!-- d="m38.3682,9.27721c2.5423,2.4953 3.5456,6.1544 2.6289,9.5881c-0.9166,3.4337 -3.6127,6.1156 -7.0646,7.0274c-3.4519,0.9119 -7.1303,-0.0861 -9.63875,-2.6151c-3.77167,-3.8845 -3.71773,-10.0592 0.12124,-13.878c3.83901,-3.8187 10.04631,-3.8724 13.95141,-0.1206l0.0018,-0.0018z"-->
<!-- :fill="props.fillColor" stroke="currentColor" :stroke-width="props.strokeWidth" stroke-linejoin="round"/>-->
<!-- <path d="m6.5,40.5l17,-17" stroke="currentColor" :stroke-width="props.strokeWidth" stroke-linecap="round"-->
<!-- stroke-linejoin="round"/>-->
<!-- <path d="m10.5,36.8l5.4285,5.4l6.3334,-6.3l-5.4286,-5.4l-6.3333,6.3z" :fill="props.fillColor"-->
<!-- stroke="currentColor" :stroke-width="props.strokeWidth" stroke-linejoin="round"/>-->
<!-- </svg>-->
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,47 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
fillColor: {
type: String,
default: '#f2c55c',
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:fill="props.fillColor"
d="M4 11.9143L24 19L44 11.9143L24 5L4 11.9143Z"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="3"
/>
<path
:stroke-width="props.strokeWidth"
d="M4 20L24 27L44 20"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M4 28L24 35L44 28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M4 36L24 43L44 36"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,34 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
color: {
type: String,
default: 'currentColor',
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M36.7279 36.7279C33.4706 39.9853 28.9706 42 24 42C14.0589 42 6 33.9411 6 24C6 14.0589 14.0589 6 24 6C28.9706 6 33.4706 8.01472 36.7279 11.2721C38.3859 12.9301 42 17 42 17"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
class="default-stroke"
d="M42 8V17H33"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,31 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M39.3 6H8.7C7.20883 6 6 7.20883 6 8.7V39.3C6 40.7912 7.20883 42 8.7 42H39.3C40.7912 42 42 40.7912 42 39.3V8.7C42 7.20883 40.7912 6 39.3 6Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M32 6V24H15V6H32Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M26 13V17" stroke="currentColor" stroke-linecap="round" />
<path :stroke-width="props.strokeWidth" d="M10.9971 6H35.9986" stroke="currentColor" stroke-linecap="round" />
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,36 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M21 38C30.3888 38 38 30.3888 38 21C38 11.6112 30.3888 4 21 4C11.6112 4 4 11.6112 4 21C4 30.3888 11.6112 38 21 38Z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M26.657 14.3431C25.2093 12.8954 23.2093 12 21.0001 12C18.791 12 16.791 12.8954 15.3433 14.3431"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M33.2216 33.2217L41.7069 41.707"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,43 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M25 14L16 5L7 14"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M15.9917 31V5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M42 34L33 43L24 34"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M32.9917 17V43"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,51 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<circle :stroke-width="props.strokeWidth" cx="24" cy="28" fill="none" r="16" stroke="currentColor" />
<path
:stroke-width="props.strokeWidth"
d="M28 4L20 4"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M24 4V12"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M35 16L38 13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M24 28V22"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M24 28H18"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,101 @@
<script setup>
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
clickToggle: {
type: Boolean,
default: false,
},
strokeWidth: {
type: [Number, String],
default: 3,
},
fillColor: {
type: String,
default: '#dc423c',
},
})
const onToggle = () => {
if (props.clickToggle) {
emit('update:modelValue', !props.modelValue)
}
}
</script>
<template>
<svg v-if="props.modelValue" fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke="props.fillColor"
:stroke-width="props.strokeWidth"
d="M44.0001 11C44.0001 11 44 36.0623 44 38C44 41.3137 35.0457 44 24 44C12.9543 44 4.00003 41.3137 4.00003 38C4.00003 36.1423 4 11 4 11"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke="props.fillColor"
:stroke-width="props.strokeWidth"
d="M44 29C44 32.3137 35.0457 35 24 35C12.9543 35 4 32.3137 4 29"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke="props.fillColor"
:stroke-width="props.strokeWidth"
d="M44 20C44 23.3137 35.0457 26 24 26C12.9543 26 4 23.3137 4 20"
stroke-linecap="round"
stroke-linejoin="round"
/>
<ellipse
:stroke="props.fillColor"
:stroke-width="props.strokeWidth"
cx="24"
cy="10"
fill="#dc423c"
rx="20"
ry="6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<svg v-else fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M44.0001 11C44.0001 11 44 36.0623 44 38C44 41.3137 35.0457 44 24 44C12.9543 44 4.00003 41.3137 4.00003 38C4.00003 36.1423 4 11 4 11"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M44 29C44 32.3137 35.0457 35 24 35C12.9543 35 4 32.3137 4 29"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M44 20C44 23.3137 35.0457 26 24 26C12.9543 26 4 23.3137 4 20"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<ellipse
:stroke-width="props.strokeWidth"
cx="24"
cy="10"
fill="none"
rx="20"
ry="6"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,64 @@
<script setup>
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
strokeWidth: {
type: [Number, String],
default: 3,
},
fillColor: {
type: String,
default: '#ffce78',
},
})
</script>
<template>
<svg v-if="props.modelValue" fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M4 9V41L9 21H39.5V15C39.5 13.8954 38.6046 13 37.5 13H24L19 7H6C4.89543 7 4 7.89543 4 9Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:fill="props.fillColor"
:stroke-width="props.strokeWidth"
d="M40 41L44 21H8.8125L4 41H40Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<svg v-else fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:fill="props.fillColor"
:stroke-width="props.strokeWidth"
d="M5 8C5 6.89543 5.89543 6 7 6H19L24 12H41C42.1046 12 43 12.8954 43 14V40C43 41.1046 42.1046 42 41 42H7C5.89543 42 5 41.1046 5 40V8Z"
stroke="currentColor"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M43 22H5" stroke="currentColor" stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M5 16V28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M43 16V28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,125 @@
<script setup>
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
clickToggle: {
type: Boolean,
default: false,
},
strokeWidth: {
type: [Number, String],
default: 3,
},
fillColor: {
type: String,
default: '#dc423c',
},
})
const onToggle = () => {
if (props.clickToggle) {
emit('update:modelValue', !props.modelValue)
}
}
</script>
<template>
<svg
v-if="props.modelValue"
:height="props.size"
:width="props.size"
fill="none"
viewBox="0 0 48 48"
xmlns="http://www.w3.org/2000/svg"
>
<path
:stroke="props.fillColor"
:stroke-width="props.strokeWidth"
d="M41 4H7C5.34315 4 4 5.34315 4 7V41C4 42.6569 5.34315 44 7 44H41C42.6569 44 44 42.6569 44 41V7C44 5.34315 42.6569 4 41 4Z"
fill="#dc423c"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M4 32H44" stroke="#FFF" stroke-linecap="round" />
<path
:stroke-width="props.strokeWidth"
d="M10 38H11"
stroke="#FFF"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M26 38H38"
stroke="#FFF"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke="props.fillColor"
:stroke-width="props.strokeWidth"
d="M44 37V27"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke="props.fillColor"
:stroke-width="props.strokeWidth"
d="M4 37V27"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<svg
v-else
:height="props.size"
:width="props.size"
fill="none"
viewBox="0 0 48 48"
xmlns="http://www.w3.org/2000/svg"
>
<path
:stroke-width="props.strokeWidth"
d="M41 4H7C5.34315 4 4 5.34315 4 7V41C4 42.6569 5.34315 44 7 44H41C42.6569 44 44 42.6569 44 41V7C44 5.34315 42.6569 4 41 4Z"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path :stroke-width="props.strokeWidth" d="M4 32H44" stroke="currentColor" stroke-linecap="round" />
<path
:stroke-width="props.strokeWidth"
d="M10 38H11"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M26 38H38"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M44 37V27"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="props.strokeWidth"
d="M4 37V27"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,59 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="strokeWidth"
d="M37 22.0001L34 25.0001L23 14.0001L26 11.0001C27.5 9.50002 33 7.00005 37 11.0001C41 15.0001 38.5 20.5 37 22.0001Z"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M42 6L37 11"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M11 25.9999L14 22.9999L25 33.9999L22 36.9999C20.5 38.5 15 41 11 36.9999C7 32.9999 9.5 27.5 11 25.9999Z"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M23 32L27 28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M6 42L11 37"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
:stroke-width="strokeWidth"
d="M16 25L20 21"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,69 @@
<script setup>
import { ref } from 'vue'
import { flatMap, reject } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../IconButton.vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
type: Number,
value: Array,
})
const emit = defineEmits(['update:value', 'update:type'])
const i18n = useI18n()
const updateOption = [
{
value: 0,
label: i18n.t('overwrite_field'),
},
{
value: 1,
label: i18n.t('ignore_field'),
},
]
/**
* @typedef Hash
* @property {string} key
* @property {string} [value]
*/
const kvList = ref([{ key: '', value: '' }])
/**
*
* @param {Hash[]} val
*/
const onUpdate = (val) => {
val = reject(val, { key: '' })
emit(
'update:value',
flatMap(val, (item) => [item.key, item.value])
)
}
</script>
<template>
<n-form-item :label="$t('type')">
<n-radio-group :value="props.type" @update:value="(val) => emit('update:type', val)">
<n-radio-button v-for="(op, i) in updateOption" :key="i" :label="op.label" :value="op.value" />
</n-radio-group>
</n-form-item>
<n-form-item :label="$t('element')" required>
<n-dynamic-input
v-model:value="kvList"
:key-placeholder="$t('enter_field')"
:value-placeholder="$t('enter_value')"
preset="pair"
@update:value="onUpdate"
>
<template #action="{ index, create, remove, move }">
<icon-button v-if="kvList.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,50 @@
<script setup>
import { ref } from 'vue'
import { compact } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../IconButton.vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
type: Number,
value: Array,
})
const emit = defineEmits(['update:value', 'update:type'])
const i18n = useI18n()
const insertOption = [
{
value: 0,
label: i18n.t('append_item'),
},
{
value: 1,
label: i18n.t('prepend_item'),
},
]
const list = ref([''])
const onUpdate = (val) => {
val = compact(val)
emit('update:value', val)
}
</script>
<template>
<n-form-item :label="$t('type')">
<n-radio-group :value="props.type" @update:value="(val) => emit('update:type', val)">
<n-radio-button v-for="(op, i) in insertOption" :key="i" :label="op.label" :value="op.value" />
</n-radio-group>
</n-form-item>
<n-form-item :label="$t('element')" required>
<n-dynamic-input v-model:value="list" :placeholder="$t('enter_elem')" @update:value="onUpdate">
<template #action="{ index, create, remove, move }">
<icon-button v-if="list.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue'
import { isEmpty, reject } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../IconButton.vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
type: Number,
value: Object,
})
const emit = defineEmits(['update:value', 'update:type'])
const i18n = useI18n()
const updateOption = [
{
value: 0,
label: i18n.t('overwrite_field'),
},
{
value: 1,
label: i18n.t('ignore_field'),
},
]
/**
* @typedef ZSetItem
* @property {string} value
* @property {string} score
*/
const zset = ref([{ value: '', score: 0 }])
const onCreate = () => {
return {
value: '',
score: 0,
}
}
/**
* update input items
*/
const onUpdate = () => {
const val = reject(zset.value, (v) => v == null || isEmpty(v.value))
const result = {}
for (const elem of val) {
result[elem.value] = elem.score
}
emit('update:value', result)
}
</script>
<template>
<n-form-item :label="$t('type')">
<n-radio-group :value="props.type" @update:value="(val) => emit('update:type', val)">
<n-radio-button v-for="(op, i) in updateOption" :key="i" :label="op.label" :value="op.value" />
</n-radio-group>
</n-form-item>
<n-form-item :label="$t('element')" required>
<n-dynamic-input v-model:value="zset" @create="onCreate" @update:value="onUpdate">
<template #default="{ value }">
<n-input
v-model:value="value.value"
:placeholder="$t('enter_elem')"
type="text"
@update:value="onUpdate"
/>
<n-input-number v-model:value="value.score" :placeholder="$t('enter_score')" @update:value="onUpdate" />
</template>
<template #action="{ index, create, remove, move }">
<icon-button v-if="zset.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss"></style>

View File

@ -0,0 +1,50 @@
<script setup>
import { ref } from 'vue'
import { flatMap, reject } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../IconButton.vue'
const props = defineProps({
value: Array,
})
const emit = defineEmits(['update:value'])
/**
* @typedef Hash
* @property {string} key
* @property {string} [value]
*/
const kvList = ref([{ key: '', value: '' }])
/**
*
* @param {Hash[]} val
*/
const onUpdate = (val) => {
val = reject(val, { key: '' })
emit(
'update:value',
flatMap(val, (item) => [item.key, item.value])
)
}
</script>
<template>
<n-form-item :label="$t('element')" required>
<n-dynamic-input
v-model:value="kvList"
:key-placeholder="$t('enter_field')"
:value-placeholder="$t('enter_value')"
preset="pair"
@update:value="onUpdate"
>
<template #action="{ index, create, remove, move }">
<icon-button v-if="kvList.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,31 @@
<script setup>
import { ref } from 'vue'
import { compact } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../IconButton.vue'
const props = defineProps({
value: Array,
})
const emit = defineEmits(['update:value'])
const list = ref([''])
const onUpdate = (val) => {
val = compact(val)
emit('update:value', val)
}
</script>
<template>
<n-form-item :label="$t('element')" required>
<n-dynamic-input v-model:value="list" :placeholder="$t('enter_elem')" @update:value="onUpdate">
<template #action="{ index, create, remove, move }">
<icon-button v-if="list.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,31 @@
<script setup>
import { ref } from 'vue'
import { compact, uniq } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../IconButton.vue'
const props = defineProps({
value: Array,
})
const emit = defineEmits(['update:value'])
const set = ref([''])
const onUpdate = (val) => {
val = uniq(compact(val))
emit('update:value', val)
}
</script>
<template>
<n-form-item :label="$t('element')" required>
<n-dynamic-input v-model:value="set" :placeholder="$t('enter_elem')" @update:value="onUpdate">
<template #action="{ index, create, remove, move }">
<icon-button v-if="set.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,21 @@
<script setup>
const props = defineProps({
value: String,
})
const emit = defineEmits(['update:value'])
</script>
<template>
<n-form-item :label="$t('value')">
<n-input
:rows="6"
:value="props.value"
placeholder=""
type="textarea"
@input="(val) => emit('update:value', val)"
/>
</n-form-item>
</template>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,57 @@
<script setup>
import { ref } from 'vue'
import { flatMap, isEmpty, reject } from 'lodash'
import Add from '../icons/Add.vue'
import Delete from '../icons/Delete.vue'
import IconButton from '../IconButton.vue'
const props = defineProps({
value: Array,
})
const emit = defineEmits(['update:value'])
/**
* @typedef ZSetItem
* @property {string} value
* @property {string} score
*/
const zset = ref([{ value: '', score: 0 }])
const onCreate = () => {
return {
value: '',
score: 0,
}
}
/**
* update input items
*/
const onUpdate = () => {
const val = reject(zset.value, (v) => v == null || isEmpty(v.value))
emit(
'update:value',
flatMap(val, (item) => [item.value, item.score])
)
}
</script>
<template>
<n-form-item :label="$t('element')" required>
<n-dynamic-input v-model:value="zset" @create="onCreate" @update:value="onUpdate">
<template #default="{ value }">
<n-input
v-model:value="value.value"
:placeholder="$t('enter_member')"
type="text"
@update:value="onUpdate"
/>
<n-input-number v-model:value="value.score" :placeholder="$t('enter_score')" @update:value="onUpdate" />
</template>
<template #action="{ index, create, remove, move }">
<icon-button v-if="zset.length > 1" :icon="Delete" size="18" @click="() => remove(index)" />
<icon-button :icon="Add" size="18" @click="() => create(index)" />
</template>
</n-dynamic-input>
</n-form-item>
</template>
<style lang="scss"></style>

View File

@ -0,0 +1,7 @@
export const ConnectionType = {
Group: 0,
Server: 1,
RedisDB: 2,
RedisKey: 3,
RedisValue: 4,
}

View File

@ -0,0 +1,13 @@
export const types = {
STRING: 'STRING',
HASH: 'HASH',
LIST: 'LIST',
SET: 'SET',
ZSET: 'ZSET',
}
// export const typesName = Object.fromEntries(Object.entries(types).map(([key, value]) => [key, value.name]))
export const validType = (t) => {
return types.hasOwnProperty(t)
}

View File

@ -0,0 +1,9 @@
import { ConnectionType } from './connection_type.js'
const i18n = useI18n()
export const contextMenuKey = {
[ConnectionType.Server]: {
key: '',
label: '',
},
}

View File

@ -0,0 +1,6 @@
export const types = {
PLAIN_TEXT: 'Plain Text',
JSON: 'JSON',
BASE64_TO_TEXT: 'Base64 To Text',
BASE64_TO_JSON: 'Base64 To JSON',
}

107
frontend/src/langs/en.json Normal file
View File

@ -0,0 +1,107 @@
{
"lang_name": "English",
"confirm": "Confirm",
"cancel": "Cancel",
"success": "Success",
"warning": "Warning",
"save": "Save",
"new_conn": "Add New Connection",
"new_group": "Add New Group",
"sort_conn": "Resort Connections",
"reload_key": "Reload Current Key",
"ttl": "TTL",
"forever": "Forever",
"rename_key": "Rename Key",
"delete_key": "Delete Key",
"delete_key_tip": "\"{key}\" will be deleted",
"delete_key_succ": "\"{key}\" has been deleted",
"copy_value": "Copy Value",
"edit_value": "Edit Value",
"save_update": "Save Update",
"cancel_update": "Cancel Update",
"add_row": "Add Row",
"edit_row": "Edit Row",
"delete_row": "Delete Row",
"search": "Search",
"view_as": "View As",
"reload": "Reload",
"open_connection": "Open Connection",
"open_db": "Expand Database",
"filter_key": "Filter Keys",
"disconnect": "Disconnect",
"dup_conn": "Duplicate Connection",
"remove_conn": "Delete Connection",
"config_conn": "Edit Connection Config",
"config_conn_group": "Edit Connection Group",
"remove_conn_group": "Delete Connection Group",
"copy_path": "Copy Path",
"remove_path": "Remove Path",
"copy_key": "Copy Key Name",
"remove_key": "Remove Key",
"new_conn_title": "New Connection",
"general": "General",
"advanced": "Advanced",
"editor": "Editor",
"conn_name": "Name",
"conn_addr": "Address",
"conn_usr": "Username",
"conn_pwd": "Password",
"conn_name_tip": "Connection name",
"conn_addr_tip": "Redis server host",
"conn_usr_tip": "(Optional) Redis server username",
"conn_pwd_tip": "(Optional) Redis server authentication password (Redis > 6.0)",
"conn_test": "Test Connection",
"conn_test_succ": "Successful connection to redis-server",
"conn_test_fail": "Fail Connection",
"conn_advn_filter": "Default Filter",
"conn_advn_filter_tip": "Pattern which defines loaded keys from redis server",
"conn_advn_separator": "Key Separator",
"conn_advn_separator_tip": "Separator used for key path item",
"conn_advn_conn_timeout": "Connection Timeout",
"conn_advn_exec_timeout": "Execution Timeout",
"conn_advn_mark_color": "Mark Color",
"new_conn_succ": "Create new connection success!",
"second": "Second(s)",
"new_key_name": "New Key Name",
"new_key": "Add New Key",
"new_field": "Add New Field",
"overwrite_field": "Overwrite Duplicated Field",
"ignore_field": "Ignore Duplicated Field",
"new_item": "Add New Item",
"insert_type": "Insert",
"append_item": "Append",
"prepend_item": "Prepend",
"enter_key": "Enter Key",
"enter_value": "Enter Value",
"enter_field": "Enter Field Name",
"enter_elem": "Enter Element",
"enter_member": "Enter Member",
"enter_score": "Enter Score",
"key": "Key",
"value": "Value",
"field": "Field",
"action": "Action",
"type": "Type",
"score": "Score",
"order_no": "Order",
"preferences": "Preferences",
"language": "Language",
"font": "Font",
"font_size": "Font Size",
"restore_defaults": "Restore Defaults",
"proxy": "Proxy",
"use_system_proxy": "Use system proxy",
"use_system_proxy_http": "Use system proxy only for HTTP(S) request",
"update": "Update",
"auto_check_update": "Automatically check for updates",
"set_ttl": "Set Key TTL",
"persist_key": "Persist Key",
"copy_value_succ": "Value Copied !",
"save_value_succ": "Value Saved !",
"handle_succ": "Handle Success !",
"field_required": "This item should not be blank",
"spec_field_required": "\"{key}\" should not be blank",
"no_connections": "No Connection",
"empty_tab_content": "Select the key from left list to see the details of the key.",
"reload_when_succ": "Reload immediately after success"
}

View File

@ -0,0 +1,7 @@
import en from './en'
import zh from './zh-cn'
export const lang = {
en,
zh,
}

View File

@ -0,0 +1,110 @@
{
"lang_name": "简体中文",
"confirm": "确认",
"cancel": "取消",
"success": "成功",
"warning": "警告",
"save": "保存",
"new_conn": "添加新连接",
"new_group": "添加新分组",
"sort_conn": "调整连接顺序",
"reload_key": "重新载入此键内容",
"ttl": "TTL",
"forever": "永久",
"rename_key": "重命名键",
"delete_key": "删除键",
"delete_key_tip": "{key} 将会被删除",
"delete_key_succ": "{key} 已被删除",
"copy_value": "复制值",
"edit_value": "修改值",
"save_update": "保存修改",
"cancel_update": "取消修改",
"add_row": "插入行",
"edit_row": "编辑行",
"delete_row": "删除行",
"search": "搜索",
"filter_field": "筛选字段",
"filter_value": "筛选值",
"view_as": "查看方式",
"reload": "重新载入",
"open_connection": "打开连接",
"open_db": "展开数据库",
"filter_key": "过滤键",
"disconnect": "断开连接",
"dup_conn": "复制连接",
"remove_conn": "删除连接",
"config_conn": "编辑连接配置",
"config_conn_group": "编辑连接分组",
"remove_conn_group": "删除连接分组",
"copy_path": "复制路径",
"remove_path": "删除路径",
"copy_key": "复制键名",
"remove_key": "删除键",
"new_conn_title": "新建连接",
"general": "常规配置",
"advanced": "高级配置",
"editor": "编辑器",
"conn_name": "连接名",
"conn_addr": "连接地址",
"conn_usr": "用户名",
"conn_pwd": "密码",
"conn_name_tip": "连接名",
"conn_addr_tip": "Redis服务地址",
"conn_usr_tip": "(可选)Redis服务授权用户名",
"conn_pwd_tip": "(可选)Redis服务授权密码 (Redis > 6.0)",
"conn_test": "测试连接",
"conn_test_succ": "成功连接到Redis服务器",
"conn_test_fail": "连接失败",
"conn_advn_filter": "默认过滤",
"conn_advn_filter_tip": "需要加载的键名表达式",
"conn_advn_separator": "键分隔符",
"conn_advn_separator_tip": "键名路径分隔符",
"conn_advn_conn_timeout": "连接超时",
"conn_advn_exec_timeout": "执行超时",
"conn_advn_mark_color": "标记颜色",
"new_conn_succ": "新建连接成功",
"second": "秒",
"new_key_name": "新键名",
"new_key": "添加新键",
"new_field": "添加新字段",
"overwrite_field": "覆盖同名字段",
"ignore_field": "忽略同名字段",
"new_item": "添加新元素",
"insert_type": "插入类型",
"append_item": "尾部追加",
"prepend_item": "插入头部",
"enter_key": "输入键名",
"enter_value": "输入值",
"enter_field": "输入字段名",
"enter_elem": "输入新元素",
"enter_member": "输入成员",
"enter_score": "输入分值",
"element": "元素",
"key": "键",
"value": "值",
"field": "字段",
"action": "操作",
"type": "类型",
"score": "分值",
"order_no": "序号",
"preferences": "偏好设置",
"language": "语言",
"font": "字体",
"font_size": "字体尺寸",
"restore_defaults": "重置为默认",
"proxy": "代理",
"use_system_proxy": "使用系统代理",
"use_system_proxy_http": "仅在HTTP请求时使用系统代理",
"update": "更新",
"auto_check_update": "自动检查更新",
"set_ttl": "设置键存活时间",
"persist_key": "持久化键",
"copy_succ": "已复制到剪切板",
"save_value_succ": "已保存值",
"handle_succ": "操作成功",
"field_required": "此项不能为空",
"spec_field_required": "{key} 不能为空",
"no_connections": "空空如也",
"empty_tab_content": "可以从左边选择键来查看键的详细内容",
"reload_when_succ": "操作成功后立即重新加载"
}

21
frontend/src/main.js Normal file
View File

@ -0,0 +1,21 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import App from './App.vue'
import { lang } from './langs'
import './style.scss'
const app = createApp(App)
app.use(
createI18n({
locale: 'en',
fallbackLocale: 'en',
globalInjection: true,
legacy: false,
messages: {
...lang,
},
})
)
app.use(createPinia())
app.mount('#app')

View File

@ -0,0 +1,904 @@
import { get, isEmpty, last, remove, size, sortedIndexBy, split } from 'lodash'
import { defineStore } from 'pinia'
import {
AddHashField,
AddListItem,
AddZSetValue,
CloseConnection,
GetKeyValue,
ListConnection,
OpenConnection,
OpenDatabase,
RemoveKey,
RenameKey,
SetHashValue,
SetKeyTTL,
SetKeyValue,
SetListItem,
SetSetItem,
UpdateSetItem,
UpdateZSetValue,
} from '../../wailsjs/go/services/connectionService.js'
import { ConnectionType } from '../consts/connection_type.js'
import useTabStore from './tab.js'
const separator = ':'
const useConnectionStore = defineStore('connection', {
/**
* @typedef {Object} ConnectionItem
* @property {string} key
* @property {string} label
* @property {string} name - server name, type != ConnectionType.Group only
* @property {number} type
* @property {number} [db] - database index, type == ConnectionType.RedisDB only
* @property {number} keys
* @property {boolean} [connected] - redis server is connected, type == ConnectionType.Server only
* @property {boolean} [opened] - redis db is opened, type == ConnectionType.RedisDB only
* @property {boolean} [expanded] - current node is expanded
*/
/**
*
* @returns {{connections: ConnectionItem[]}}
*/
state: () => ({
connections: [], // all connections list
}),
getters: {},
actions: {
/**
* Load all store connections struct from local profile
* @returns {Promise<void>}
*/
async initConnection() {
if (!isEmpty(this.connections)) {
return
}
const { data = [{ groupName: '', connections: [] }] } = await ListConnection()
for (let i = 0; i < data.length; i++) {
const group = data[i]
// Top level group
if (isEmpty(group.groupName)) {
for (let j = 0; j < group.connections.length; j++) {
const item = group.connections[j]
this.connections.push({
key: item.name,
label: item.name,
name: item.name,
type: ConnectionType.Server,
// isLeaf: false,
})
}
} else {
// Custom group
const children = []
for (let j = 0; j < group.connections.length; j++) {
const item = group.connections[j]
const value = group.groupName + '/' + item.name
children.push({
key: value,
label: item.name,
name: item.name,
type: ConnectionType.Server,
children: j === group.connections.length - 1 ? undefined : [],
// isLeaf: false,
})
}
this.connections.push({
key: group.groupName,
label: group.groupName,
type: ConnectionType.Group,
children,
})
}
}
console.debug(JSON.stringify(this.connections))
},
/**
* Open connection
* @param {string} connName
* @returns {Promise<void>}
*/
async openConnection(connName) {
const { data, success, msg } = await OpenConnection(connName)
if (!success) {
throw new Error(msg)
}
// append to db node to current connection
const connNode = this.getConnection(connName)
if (connNode == null) {
throw new Error('no such connection')
}
const { db } = data
if (isEmpty(db)) {
throw new Error('no db loaded')
}
const children = []
for (let i = 0; i < db.length; i++) {
children.push({
key: `${connName}/${db[i].name}`,
label: db[i].name,
name: connName,
keys: db[i].keys,
db: i,
type: ConnectionType.RedisDB,
// isLeaf: false,
})
}
connNode.children = children
connNode.connected = true
},
/**
* Close connection
* @param {string} connName
* @returns {Promise<boolean>}
*/
async closeConnection(connName) {
const { success, msg } = await CloseConnection(connName)
if (!success) {
// throw new Error(msg)
return false
}
// successful close connection, remove all children
const connNode = this.getConnection(connName)
if (connNode == null) {
// throw new Error('no such connection')
return false
}
connNode.children = undefined
connNode.isLeaf = undefined
connNode.connected = false
connNode.expanded = false
return true
},
/**
* Get Connection by path name
* @param {string} connName
* @returns
*/
getConnection(connName) {
const conn = this.connections
for (let i = 0; i < conn.length; i++) {
if (conn[i].type === ConnectionType.Server && conn[i].key === connName) {
return conn[i]
} else if (conn[i].type === ConnectionType.Group) {
const children = conn[i].children
for (let j = 0; j < children.length; j++) {
if (children[j].type === ConnectionType.Server && conn[i].key === connName) {
return children[j]
}
}
}
}
return null
},
/**
* Open database and load all keys
* @param connName
* @param db
* @returns {Promise<void>}
*/
async openDatabase(connName, db) {
const { data, success, msg } = await OpenDatabase(connName, db)
if (!success) {
throw new Error(msg)
}
const { keys = [] } = data
if (isEmpty(keys)) {
const connNode = this.getConnection(connName)
const { children = [] } = connNode
children[db].children = []
children[db].opened = true
return
}
// insert child to children list by order
const sortedInsertChild = (childrenList, item) => {
const insertIdx = sortedIndexBy(childrenList, item, 'key')
childrenList.splice(insertIdx, 0, item)
// childrenList.push(item)
}
// update all node item's children num
const updateChildrenNum = (node) => {
let count = 0
const totalChildren = size(node.children)
if (totalChildren > 0) {
for (const elem of node.children) {
updateChildrenNum(elem)
count += elem.keys
}
} else {
count += 1
}
node.keys = count
// node.children = sortBy(node.children, 'label')
}
const keyStruct = []
const mark = {}
for (const key in keys) {
const keyPart = split(key, separator)
// const prefixLen = size(keyPart) - 1
const len = size(keyPart)
let handlePath = ''
let ks = keyStruct
for (let i = 0; i < len; i++) {
handlePath += keyPart[i]
if (i !== len - 1) {
// layer
const treeKey = `${handlePath}@${ConnectionType.RedisKey}`
if (!mark.hasOwnProperty(treeKey)) {
mark[treeKey] = {
key: `${connName}/db${db}/${treeKey}`,
label: keyPart[i],
name: connName,
db,
keys: 0,
redisKey: handlePath,
type: ConnectionType.RedisKey,
children: [],
}
sortedInsertChild(ks, mark[treeKey])
}
ks = mark[treeKey].children
handlePath += separator
} else {
// key
const treeKey = `${handlePath}@${ConnectionType.RedisValue}`
mark[treeKey] = {
key: `${connName}/db${db}/${treeKey}`,
label: keyPart[i],
name: connName,
db,
keys: 0,
redisKey: handlePath,
type: ConnectionType.RedisValue,
}
sortedInsertChild(ks, mark[treeKey])
}
}
}
// append db node to current connection's children
const connNode = this.getConnection(connName)
const { children = [] } = connNode
children[db].children = keyStruct
children[db].opened = true
updateChildrenNum(children[db])
},
/**
* select node
* @param key
* @param name
* @param db
* @param type
* @param redisKey
*/
select({ key, name, db, type, redisKey }) {
if (type === ConnectionType.RedisValue) {
console.log(`[click]key:${key} db: ${db} redis key: ${redisKey}`)
// async get value for key
this.loadKeyValue(name, db, redisKey).then(() => {})
}
},
/**
* load redis key
* @param server
* @param db
* @param key
*/
async loadKeyValue(server, db, key) {
try {
const { data, success, msg } = await GetKeyValue(server, db, key)
if (success) {
const { type, ttl, value } = data
const tab = useTabStore()
tab.upsertTab({
server,
db,
type,
ttl,
key,
value,
})
} else {
console.warn('TODO: handle get key fail')
}
} finally {
}
},
/**
*
* @param {string} connName
* @param {number} db
* @param {string} key
* @private
*/
_addKey(connName, db, key) {
const connNode = this.getConnection(connName)
const { children: dbs = [] } = connNode
const dbDetail = get(dbs, db, {})
if (dbDetail == null) {
return
}
const descendantChain = [dbDetail]
const keyPart = split(key, separator)
let redisKey = ''
const keyLen = size(keyPart)
let added = false
for (let i = 0; i < keyLen; i++) {
redisKey += keyPart[i]
const node = last(descendantChain)
const nodeList = get(node, 'children', [])
const len = size(nodeList)
const isLastKeyPart = i === keyLen - 1
for (let j = 0; j < len + 1; j++) {
const treeKey = get(nodeList[j], 'key')
const isLast = j >= len - 1
const currentKey = `${connName}/db${db}/${redisKey}@${
isLastKeyPart ? ConnectionType.RedisValue : ConnectionType.RedisKey
}`
if (treeKey > currentKey || isLast) {
// out of search range, add new item
if (isLastKeyPart) {
// key not exists, add new one
const item = {
key: currentKey,
label: keyPart[i],
name: connName,
db,
keys: 1,
redisKey,
type: ConnectionType.RedisValue,
}
if (isLast) {
nodeList.push(item)
} else {
nodeList.splice(j, 0, item)
}
added = true
} else {
// layer not exists, add new one
const item = {
key: currentKey,
label: keyPart[i],
name: connName,
db,
keys: 0,
redisKey,
type: ConnectionType.RedisKey,
children: [],
}
if (isLast) {
nodeList.push(item)
descendantChain.push(last(nodeList))
} else {
nodeList.splice(j, 0, item)
descendantChain.push(nodeList[j])
}
redisKey += separator
added = true
}
break
} else if (treeKey === currentKey) {
if (isLastKeyPart) {
// same key exists, do nothing
console.log('TODO: same key exist, do nothing now, should replace value later')
} else {
// same group exists, find into it's children
descendantChain.push(nodeList[j])
redisKey += separator
}
break
}
}
}
// update ancestor node's info
if (added) {
const desLen = size(descendantChain)
for (let i = 0; i < desLen; i++) {
const children = get(descendantChain[i], 'children', [])
let keys = 0
for (const child of children) {
if (child.type === ConnectionType.RedisKey) {
keys += get(child, 'keys', 1)
} else if (child.type === ConnectionType.RedisValue) {
keys += get(child, 'keys', 0)
}
}
descendantChain[i].keys = keys
}
}
},
/**
* set redis key
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {number} keyType
* @param {any} value
* @param {number} ttl
* @returns {Promise<{[msg]: string, success: boolean}>}
*/
async setKey(connName, db, key, keyType, value, ttl) {
try {
const { data, success, msg } = await SetKeyValue(connName, db, key, keyType, value, ttl)
if (success) {
// update tree view data
this._addKey(connName, db, key)
return { success }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* update hash field
* when field is set, newField is null, delete field
* when field is null, newField is set, add new field
* when both field and newField are set, and field === newField, update field
* when both field and newField are set, and field !== newField, delete field and add newField
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string} field
* @param {string} newField
* @param {string} value
* @returns {Promise<{[msg]: string, success: boolean, [updated]: {}}>}
*/
async setHash(connName, db, key, field, newField, value) {
try {
const { data, success, msg } = await SetHashValue(connName, db, key, field, newField || '', value || '')
if (success) {
const { updated = {} } = data
return { success, updated }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* insert or update hash field item
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {number }action 0:ignore duplicated fields 1:overwrite duplicated fields
* @param {string[]} fieldItems field1, value1, filed2, value2...
* @returns {Promise<{[msg]: string, success: boolean, [updated]: {}}>}
*/
async addHashField(connName, db, key, action, fieldItems) {
try {
const { data, success, msg } = await AddHashField(connName, db, key, action, fieldItems)
if (success) {
const { updated = {} } = data
return { success, updated }
} else {
return { success: false, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* remove hash field
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string} field
* @returns {Promise<{[msg]: {}, success: boolean, [removed]: string[]}>}
*/
async removeHashField(connName, db, key, field) {
try {
const { data, success, msg } = await SetHashValue(connName, db, key, field, '', '')
if (success) {
const { removed = [] } = data
return { success, removed }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* insert list item
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {int} action 0: push to head, 1: push to tail
* @param {string[]}values
* @returns {Promise<*|{msg, success: boolean}>}
*/
async addListItem(connName, db, key, action, values) {
try {
return AddListItem(connName, db, key, action, values)
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* prepend item to head of list
* @param connName
* @param db
* @param key
* @param values
* @returns {Promise<[msg]: string, success: boolean, [item]: []>}
*/
async prependListItem(connName, db, key, values) {
try {
const { data, success, msg } = await AddListItem(connName, db, key, 0, values)
if (success) {
const { left = [] } = data
return { success, item: left }
} else {
return { success: false, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* append item to tail of list
* @param connName
* @param db
* @param key
* @param values
* @returns {Promise<[msg]: string, success: boolean, [item]: any[]>}
*/
async appendListItem(connName, db, key, values) {
try {
const { data, success, msg } = await AddListItem(connName, db, key, 1, values)
if (success) {
const { right = [] } = data
return { success, item: right }
} else {
return { success: false, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* update value of list item by index
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {number} index
* @param {string} value
* @returns {Promise<{[msg]: string, success: boolean, [updated]: {}}>}
*/
async updateListItem(connName, db, key, index, value) {
try {
const { data, success, msg } = await SetListItem(connName, db, key, index, value)
if (success) {
const { updated = {} } = data
return { success, updated }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* remove list item
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {number} index
* @returns {Promise<{[msg]: string, success: boolean, [removed]: string[]}>}
*/
async removeListItem(connName, db, key, index) {
try {
const { data, success, msg } = await SetListItem(connName, db, key, index, '')
if (success) {
const { removed = [] } = data
return { success, removed }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* add item to set
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string} value
* @returns {Promise<{[msg]: string, success: boolean}>}
*/
async addSetItem(connName, db, key, value) {
try {
const { success, msg } = await SetSetItem(connName, db, key, false, [value])
if (success) {
return { success }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* update value of set item
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string} value
* @param {string} newValue
* @returns {Promise<{[msg]: string, success: boolean}>}
*/
async updateSetItem(connName, db, key, value, newValue) {
try {
const { success, msg } = await UpdateSetItem(connName, db, key, value, newValue)
if (success) {
return { success: true }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* remove item from set
* @param connName
* @param db
* @param key
* @param value
* @returns {Promise<{[msg]: string, success: boolean}>}
*/
async removeSetItem(connName, db, key, value) {
try {
const { success, msg } = await SetSetItem(connName, db, key, true, [value])
if (success) {
return { success }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* add item to sorted set
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {number} action
* @param {Object.<string, number>} vs value: score
* @returns {Promise<{[msg]: string, success: boolean}>}
*/
async addZSetItem(connName, db, key, action, vs) {
try {
const { success, msg } = await AddZSetValue(connName, db, key, action, vs)
if (success) {
return { success }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* update item of sorted set
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string} value
* @param {string} newValue
* @param {number} score
* @returns {Promise<{[msg]: string, success: boolean, [updated]: {}, [removed]: []}>}
*/
async updateZSetItem(connName, db, key, value, newValue, score) {
try {
const { data, success, msg } = await UpdateZSetValue(connName, db, key, value, newValue, score)
if (success) {
const { updated, removed } = data
return { success, updated, removed }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* remove item from sorted set
* @param {string} connName
* @param {number} db
* @param key
* @param {string} value
* @returns {Promise<{[msg]: string, success: boolean, [removed]: []}>}
*/
async removeZSetItem(connName, db, key, value) {
try {
const { data, success, msg } = await UpdateZSetValue(connName, db, key, value, '', 0)
if (success) {
const { removed } = data
return { success, removed }
} else {
return { success, msg }
}
} catch (e) {
return { success: false, msg: e.message }
}
},
/**
* reset key's ttl
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {number} ttl
* @returns {Promise<boolean>}
*/
async setTTL(connName, db, key, ttl) {
try {
const { success, msg } = await SetKeyTTL(connName, db, key, ttl)
return success === true
} catch (e) {
return false
}
},
/**
*
* @param {string} connName
* @param {number} db
* @param {string} key
* @private
*/
_removeKey(connName, db, key) {
const connNode = this.getConnection(connName)
const { children: dbs = [] } = connNode
const dbDetail = get(dbs, db, {})
if (dbDetail == null) {
return
}
const descendantChain = [dbDetail]
const keyPart = split(key, separator)
let redisKey = ''
const keyLen = size(keyPart)
let deleted = false
let forceBreak = false
for (let i = 0; i < keyLen && !forceBreak; i++) {
redisKey += keyPart[i]
const node = last(descendantChain)
const nodeList = get(node, 'children', [])
const len = size(nodeList)
const isLastKeyPart = i === keyLen - 1
for (let j = 0; j < len; j++) {
const treeKey = get(nodeList[j], 'key')
const currentKey = `${connName}/db${db}/${redisKey}@${
isLastKeyPart ? ConnectionType.RedisValue : ConnectionType.RedisKey
}`
if (treeKey > currentKey) {
// out of search range, target not exists
forceBreak = true
break
} else if (treeKey === currentKey) {
if (isLastKeyPart) {
// find target
nodeList.splice(j, 1)
node.keys -= 1
deleted = true
forceBreak = true
} else {
// find into it's children
descendantChain.push(nodeList[j])
redisKey += separator
}
break
}
}
if (forceBreak) {
break
}
}
// console.log(JSON.stringify(descendantChain))
// update ancestor node's info
if (deleted) {
const desLen = size(descendantChain)
for (let i = desLen - 1; i > 0; i--) {
const children = get(descendantChain[i], 'children', [])
const parent = descendantChain[i - 1]
if (isEmpty(children)) {
const parentChildren = get(parent, 'children', [])
const k = get(descendantChain[i], 'key')
remove(parentChildren, (item) => item.key === k)
}
parent.keys -= 1
}
}
},
/**
* remove redis key
* @param {string} connName
* @param {number} db
* @param {string} key
* @returns {Promise<boolean>}
*/
async removeKey(connName, db, key) {
try {
const { data, success, msg } = await RemoveKey(connName, db, key)
if (success) {
// update tree view data
this._removeKey(connName, db, key)
// set tab content empty
const tab = useTabStore()
tab.emptyTab(connName)
return true
}
} finally {
}
return false
},
/**
* rename key
* @param {string} connName
* @param {number} db
* @param {string} key
* @param {string} newKey
* @returns {Promise<{[msg]: string, success: boolean}>}
*/
async renameKey(connName, db, key, newKey) {
const { success = false, msg } = await RenameKey(connName, db, key, newKey)
if (success) {
// delete old key and add new key struct
this._removeKey(connName, db, key)
this._addKey(connName, db, newKey)
return { success: true }
} else {
return { success: false, msg }
}
},
},
})
export default useConnectionStore

View File

@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
const useDialogStore = defineStore('dialog', {
state: () => ({
newDialogVisible: false,
/**
* @property {string} prefix
* @property {string} server
* @property {int} db
*/
newKeyParam: {
prefix: '',
server: '',
db: 0,
},
newKeyDialogVisible: false,
addFieldParam: {
server: '',
db: 0,
key: '',
type: null,
},
addFieldsDialogVisible: false,
renameKeyParam: {
server: '',
db: 0,
key: '',
},
renameDialogVisible: false,
selectTTL: -1,
ttlDialogVisible: false,
preferencesDialogVisible: false,
}),
actions: {
openNewDialog() {
this.newDialogVisible = true
},
closeNewDialog() {
this.newDialogVisible = false
},
/**
*
* @param {string} server
* @param {number} db
* @param {string} key
*/
openRenameKeyDialog(server, db, key) {
this.renameKeyParam.server = server
this.renameKeyParam.db = db
this.renameKeyParam.key = key
this.renameDialogVisible = true
},
closeRenameKeyDialog() {
this.renameDialogVisible = false
},
/**
*
* @param {string} prefix
* @param {number} server
* @param {string} db
*/
openNewKeyDialog(prefix, server, db) {
this.newKeyParam.prefix = prefix
this.newKeyParam.server = server
this.newKeyParam.db = db
this.newKeyDialogVisible = true
},
closeNewKeyDialog() {
this.newKeyDialogVisible = false
},
/**
*
* @param {string} server
* @param {number} db
* @param {string} key
* @param {string} type
*/
openAddFieldsDialog(server, db, key, type) {
this.addFieldParam.server = server
this.addFieldParam.db = db
this.addFieldParam.key = key
this.addFieldParam.type = type
this.addFieldsDialogVisible = true
},
closeAddFieldsDialog() {
this.addFieldsDialogVisible = false
},
openTTLDialog(ttl) {
this.selectTTL = ttl
this.ttlDialogVisible = true
},
closeTTLDialog() {
this.selectTTL = -1
this.ttlDialogVisible = false
},
openPreferencesDialog() {
this.preferencesDialogVisible = true
},
closePreferencesDialog() {
this.preferencesDialogVisible = false
},
},
})
export default useDialogStore

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