mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-20 01:34:24 +08:00
support data export
This commit is contained in:
+23
-2
@@ -155,6 +155,14 @@ func startWebServer(c *cli.Context) error {
|
|||||||
apiRoute.POST("/register.json", bindApi(api.Users.UserRegistrationNotAllowed))
|
apiRoute.POST("/register.json", bindApi(api.Users.UserRegistrationNotAllowed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.EnableDataExport {
|
||||||
|
dataRoute := apiRoute.Group("/data")
|
||||||
|
dataRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString))
|
||||||
|
{
|
||||||
|
dataRoute.GET("/export.csv", bindCsv(api.DataManagements.ExportDataHandler))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
apiRoute.GET("/logout.json", bindApi(api.Tokens.TokenRevokeCurrentHandler))
|
apiRoute.GET("/logout.json", bindApi(api.Tokens.TokenRevokeCurrentHandler))
|
||||||
|
|
||||||
apiV1Route := apiRoute.Group("/v1")
|
apiV1Route := apiRoute.Group("/v1")
|
||||||
@@ -255,9 +263,22 @@ func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
|
|||||||
result, err := fn(c)
|
result, err := fn(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintErrorResult(c, err)
|
utils.PrintJsonErrorResult(c, err)
|
||||||
} else {
|
} else {
|
||||||
utils.PrintSuccessResult(c, result)
|
utils.PrintJsonSuccessResult(c, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc {
|
||||||
|
return func(ginCtx *gin.Context) {
|
||||||
|
c := core.WrapContext(ginCtx)
|
||||||
|
result, fileName, err := fn(c)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
utils.PrintDataErrorResult(c, "text/text", err)
|
||||||
|
} else {
|
||||||
|
utils.PrintDataSuccessResult(c, "text/csv", fileName, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,3 +105,7 @@ request_id_header = true
|
|||||||
[user]
|
[user]
|
||||||
# Set to true to allow users to register account by themselves
|
# Set to true to allow users to register account by themselves
|
||||||
enable_register = true
|
enable_register = true
|
||||||
|
|
||||||
|
[data]
|
||||||
|
# Set to true to allow users to export their data
|
||||||
|
enable_export = true
|
||||||
|
|||||||
+1
-6
@@ -226,12 +226,7 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs.
|
|||||||
return nil, errs.ErrOperationFailed
|
return nil, errs.ErrOperationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
accountMap := make(map[int64]*models.Account)
|
accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts)
|
||||||
|
|
||||||
for i := 0; i < len(accountAndSubAccounts); i++ {
|
|
||||||
acccount := accountAndSubAccounts[i]
|
|
||||||
accountMap[acccount.AccountId] = acccount
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := accountMap[accountModifyReq.Id]; !exists {
|
if _, exists := accountMap[accountModifyReq.Id]; !exists {
|
||||||
return nil, errs.ErrAccountNotFound
|
return nil, errs.ErrAccountNotFound
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mayswind/lab/pkg/core"
|
||||||
|
"github.com/mayswind/lab/pkg/errs"
|
||||||
|
"github.com/mayswind/lab/pkg/log"
|
||||||
|
"github.com/mayswind/lab/pkg/models"
|
||||||
|
"github.com/mayswind/lab/pkg/services"
|
||||||
|
"github.com/mayswind/lab/pkg/settings"
|
||||||
|
"github.com/mayswind/lab/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageCountForDataExport = 1000
|
||||||
|
const csvHeaderLine = "Time,Type,Category,Sub Category,Account,Amount,Account2,Account2 Amount,Tags,Comment\n"
|
||||||
|
const csvDataLineFormat = "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n"
|
||||||
|
|
||||||
|
// DataManagementsApi represents data management api
|
||||||
|
type DataManagementsApi struct {
|
||||||
|
tokens *services.TokenService
|
||||||
|
users *services.UserService
|
||||||
|
accounts *services.AccountService
|
||||||
|
transactions *services.TransactionService
|
||||||
|
categories *services.TransactionCategoryService
|
||||||
|
tags *services.TransactionTagService
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a data management api singleton instance
|
||||||
|
var (
|
||||||
|
DataManagements = &DataManagementsApi{
|
||||||
|
tokens: services.Tokens,
|
||||||
|
users: services.Users,
|
||||||
|
accounts: services.Accounts,
|
||||||
|
transactions: services.Transactions,
|
||||||
|
categories: services.TransactionCategories,
|
||||||
|
tags: services.TransactionTags,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportDataHandler returns exported data in csv format
|
||||||
|
func (a *DataManagementsApi) ExportDataHandler(c *core.Context) ([]byte, string, *errs.Error) {
|
||||||
|
if !settings.Container.Current.EnableDataExport {
|
||||||
|
return nil, "", errs.ErrDataExportNotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := c.GetCurrentUid()
|
||||||
|
|
||||||
|
accounts, err := a.accounts.GetAllAccountsByUid(uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, "", errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := a.categories.GetAllCategoriesByUid(uid, 0, -1)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, "", errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := a.tags.GetAllTagsByUid(uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, "", errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIndexs, err := a.tags.GetAllTagIdsOfAllTransactions(uid)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, "", errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
accountMap := a.accounts.GetAccountMapByList(accounts)
|
||||||
|
categoryMap := a.categories.GetCategoryMapByList(categories)
|
||||||
|
tagMap := a.tags.GetTagMapByList(tags)
|
||||||
|
|
||||||
|
maxTime := utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix())
|
||||||
|
var allTransactions []*models.Transaction
|
||||||
|
|
||||||
|
for maxTime > 0 {
|
||||||
|
transactions, err := a.transactions.GetTransactionsByMaxTime(uid, maxTime, nil, 0, 0, pageCountForDataExport)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get transactions earlier than \"%d\" for user \"uid:%d\", because %s", maxTime, uid, err.Error())
|
||||||
|
return nil, "", errs.ErrOperationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
allTransactions = append(allTransactions, transactions...)
|
||||||
|
|
||||||
|
if len(transactions) < pageCountForDataExport {
|
||||||
|
maxTime = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTime = transactions[len(transactions)-1].TransactionTime - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := a.getCSVFormatData(c, allTransactions, accountMap, categoryMap, tagMap, tagIndexs)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error())
|
||||||
|
return nil, "", errs.Or(err, errs.ErrOperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := a.getFileName(c)
|
||||||
|
|
||||||
|
return []byte(result), fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getCSVFormatData(c *core.Context, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexs map[int64][]int64) (string, error) {
|
||||||
|
|
||||||
|
var ret strings.Builder
|
||||||
|
|
||||||
|
ret.Grow(len(transactions) * 100)
|
||||||
|
ret.WriteString(csvHeaderLine)
|
||||||
|
|
||||||
|
for i := 0; i < len(transactions); i++ {
|
||||||
|
transaction := transactions[i]
|
||||||
|
|
||||||
|
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionTime := utils.FormatToLongDateTimeWithoutSecond(utils.ParseFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime)))
|
||||||
|
transactionType := a.getTransactionTypeName(c, transaction.Type)
|
||||||
|
category := a.getTransactionCategoryName(c, transaction.CategoryId, categoryMap)
|
||||||
|
subCategory := a.getTransactionSubCategoryName(c, transaction.CategoryId, categoryMap)
|
||||||
|
account := a.getAccountName(c, transaction.AccountId, accountMap)
|
||||||
|
amount := a.getDisplayAmount(c, transaction.Amount)
|
||||||
|
account2 := ""
|
||||||
|
account2Amount := ""
|
||||||
|
|
||||||
|
if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT {
|
||||||
|
account2 = a.getAccountName(c, transaction.RelatedAccountId, accountMap)
|
||||||
|
account2Amount = a.getDisplayAmount(c, transaction.RelatedAccountAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := a.getTags(c, transaction.TransactionId, allTagIndexs, tagMap)
|
||||||
|
comment := a.getComment(c, transaction.Comment)
|
||||||
|
|
||||||
|
ret.WriteString(fmt.Sprintf(csvDataLineFormat, transactionTime, transactionType, category, subCategory, account, amount, account2, account2Amount, tags, comment))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getTransactionTypeName(c *core.Context, transactionDbType models.TransactionDbType) string {
|
||||||
|
if transactionDbType == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE {
|
||||||
|
return "Balance Modification"
|
||||||
|
} else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME {
|
||||||
|
return "Income"
|
||||||
|
} else if transactionDbType == models.TRANSACTION_DB_TYPE_EXPENSE {
|
||||||
|
return "Expense"
|
||||||
|
} else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_IN {
|
||||||
|
return "Transfer"
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getTransactionCategoryName(c *core.Context, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
|
||||||
|
category, exists := categoryMap[categoryId]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if category.ParentCategoryId == 0 {
|
||||||
|
return category.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
parentCategory, exists := categoryMap[category.ParentCategoryId]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parentCategory.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getTransactionSubCategoryName(c *core.Context, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string {
|
||||||
|
category, exists := categoryMap[categoryId]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return category.Name
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getAccountName(c *core.Context, accountId int64, accountMap map[int64]*models.Account) string {
|
||||||
|
account, exists := accountMap[accountId]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return account.Name
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getDisplayAmount(c *core.Context, amount int64) string {
|
||||||
|
displayAmount := utils.Int64ToString(amount)
|
||||||
|
integer := utils.SubString(displayAmount, 0, len(displayAmount)-2)
|
||||||
|
decimals := utils.SubString(displayAmount, -2, 2)
|
||||||
|
|
||||||
|
if integer == "" {
|
||||||
|
integer = "0"
|
||||||
|
} else if integer == "-" {
|
||||||
|
integer = "-0"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decimals) == 0 {
|
||||||
|
decimals = "00"
|
||||||
|
} else if len(decimals) == 1 {
|
||||||
|
decimals = "0" + decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
return integer + "." + decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getTags(c *core.Context, transactionId int64, allTagIndexs map[int64][]int64, tagMap map[int64]*models.TransactionTag) string {
|
||||||
|
tagIndexs, exists := allTagIndexs[transactionId]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret strings.Builder
|
||||||
|
|
||||||
|
for i := 0; i < len(tagIndexs); i++ {
|
||||||
|
if i > 0 {
|
||||||
|
ret.WriteString(";")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIndex := tagIndexs[i]
|
||||||
|
tag, exists := tagMap[tagIndex]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.WriteString(tag.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getComment(c *core.Context, comment string) string {
|
||||||
|
comment = strings.Replace(comment, ",", " ", -1)
|
||||||
|
comment = strings.Replace(comment, "\r\n", " ", -1)
|
||||||
|
comment = strings.Replace(comment, "\n", " ", -1)
|
||||||
|
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DataManagementsApi) getFileName(c *core.Context) string {
|
||||||
|
currentTime := utils.FormatToLongDateTimeWithoutSecond(time.Now())
|
||||||
|
currentTime = strings.Replace(currentTime, "-", "_", -1)
|
||||||
|
currentTime = strings.Replace(currentTime, " ", "_", -1)
|
||||||
|
currentTime = strings.Replace(currentTime, ":", "_", -1)
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s.csv", currentTime)
|
||||||
|
}
|
||||||
@@ -7,3 +7,6 @@ type MiddlewareHandlerFunc func(*Context)
|
|||||||
|
|
||||||
// ApiHandlerFunc represents the api handler function
|
// ApiHandlerFunc represents the api handler function
|
||||||
type ApiHandlerFunc func(*Context) (interface{}, *errs.Error)
|
type ApiHandlerFunc func(*Context) (interface{}, *errs.Error)
|
||||||
|
|
||||||
|
// DataHandlerFunc represents the handler function that returns byte array
|
||||||
|
type DataHandlerFunc func(*Context) ([]byte, string, *errs.Error)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package errs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error codes related to data management
|
||||||
|
var (
|
||||||
|
ErrDataExportNotAllowed = NewNormalError(NormalSubcategoryDataManagement, 1, http.StatusBadRequest, "data export not allowed")
|
||||||
|
)
|
||||||
+9
-8
@@ -18,14 +18,15 @@ const (
|
|||||||
|
|
||||||
// Sub categories of normal error
|
// Sub categories of normal error
|
||||||
const (
|
const (
|
||||||
NormalSubcategoryGlobal = 0
|
NormalSubcategoryGlobal = 0
|
||||||
NormalSubcategoryUser = 1
|
NormalSubcategoryUser = 1
|
||||||
NormalSubcategoryToken = 2
|
NormalSubcategoryToken = 2
|
||||||
NormalSubcategoryTwofactor = 3
|
NormalSubcategoryTwofactor = 3
|
||||||
NormalSubcategoryAccount = 4
|
NormalSubcategoryAccount = 4
|
||||||
NormalSubcategoryTransaction = 5
|
NormalSubcategoryTransaction = 5
|
||||||
NormalSubcategoryCategory = 6
|
NormalSubcategoryCategory = 6
|
||||||
NormalSubcategoryTag = 7
|
NormalSubcategoryTag = 7
|
||||||
|
NormalSubcategoryDataManagement = 8
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error represents the specific error returned to user
|
// Error represents the specific error returned to user
|
||||||
|
|||||||
@@ -10,24 +10,26 @@ import (
|
|||||||
"github.com/mayswind/lab/pkg/utils"
|
"github.com/mayswind/lab/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tokenQueryStringParam = "token"
|
||||||
|
|
||||||
// JWTAuthorization verifies whether current request is valid by jwt token
|
// JWTAuthorization verifies whether current request is valid by jwt token
|
||||||
func JWTAuthorization(c *core.Context) {
|
func JWTAuthorization(c *core.Context) {
|
||||||
claims, err := getTokenClaims(c)
|
claims, err := getTokenClaims(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintErrorResult(c, err)
|
utils.PrintJsonErrorResult(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.Type == core.USER_TOKEN_TYPE_REQUIRE_2FA {
|
if claims.Type == core.USER_TOKEN_TYPE_REQUIRE_2FA {
|
||||||
log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token requires 2fa", claims.Id)
|
log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token requires 2fa", claims.Id)
|
||||||
utils.PrintErrorResult(c, errs.ErrCurrentTokenRequire2FA)
|
utils.PrintJsonErrorResult(c, errs.ErrCurrentTokenRequire2FA)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.Type != core.USER_TOKEN_TYPE_NORMAL {
|
if claims.Type != core.USER_TOKEN_TYPE_NORMAL {
|
||||||
log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token type is invalid", claims.Id)
|
log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token type is invalid", claims.Id)
|
||||||
utils.PrintErrorResult(c, errs.ErrCurrentInvalidTokenType)
|
utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidTokenType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,18 +37,33 @@ func JWTAuthorization(c *core.Context) {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWTAuthorizationByQueryString verifies whether current request is valid by jwt token
|
||||||
|
func JWTAuthorizationByQueryString(c *core.Context) {
|
||||||
|
token, exists := c.GetQuery(tokenQueryStringParam)
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
log.ErrorfWithRequestId(c, "[authorization.JWTAuthorizationByQueryString] no token provided")
|
||||||
|
utils.PrintJsonErrorResult(c, errs.ErrUnauthorizedAccess)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Request.Header.Set("Authorization", token)
|
||||||
|
|
||||||
|
JWTAuthorization(c)
|
||||||
|
}
|
||||||
|
|
||||||
// JWTTwoFactorAuthorization verifies whether current request is valid by 2fa passcode
|
// JWTTwoFactorAuthorization verifies whether current request is valid by 2fa passcode
|
||||||
func JWTTwoFactorAuthorization(c *core.Context) {
|
func JWTTwoFactorAuthorization(c *core.Context) {
|
||||||
claims, err := getTokenClaims(c)
|
claims, err := getTokenClaims(c)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.PrintErrorResult(c, err)
|
utils.PrintJsonErrorResult(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.Type != core.USER_TOKEN_TYPE_REQUIRE_2FA {
|
if claims.Type != core.USER_TOKEN_TYPE_REQUIRE_2FA {
|
||||||
log.WarnfWithRequestId(c, "[authorization.JWTTwoFactorAuthorization] user \"uid:%s\" token is not need two factor authorization", claims.Id)
|
log.WarnfWithRequestId(c, "[authorization.JWTTwoFactorAuthorization] user \"uid:%s\" token is not need two factor authorization", claims.Id)
|
||||||
utils.PrintErrorResult(c, errs.ErrCurrentTokenNotRequire2FA)
|
utils.PrintJsonErrorResult(c, errs.ErrCurrentTokenNotRequire2FA)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func Recovery(c *core.Context) {
|
|||||||
stack := stack(3)
|
stack := stack(3)
|
||||||
|
|
||||||
log.ErrorfWithRequestIdAndExtra(c, string(stack), "System Error! because %s", err)
|
log.ErrorfWithRequestIdAndExtra(c, string(stack), "System Error! because %s", err)
|
||||||
utils.PrintErrorResult(c, errs.ErrSystemError)
|
utils.PrintJsonErrorResult(c, errs.ErrSystemError)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc {
|
|||||||
return func(c *core.Context) {
|
return func(c *core.Context) {
|
||||||
settingsArr := []string{
|
settingsArr := []string{
|
||||||
buildBooleanSetting("r", config.EnableUserRegister),
|
buildBooleanSetting("r", config.EnableUserRegister),
|
||||||
|
buildBooleanSetting("e", config.EnableDataExport),
|
||||||
}
|
}
|
||||||
|
|
||||||
bundledSettings := strings.Join(settingsArr, "_")
|
bundledSettings := strings.Join(settingsArr, "_")
|
||||||
|
|||||||
@@ -321,3 +321,14 @@ func (s *AccountService) DeleteAccount(uid int64, accountId int64) error {
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccountMapByList returns an account map by a list
|
||||||
|
func (s *AccountService) GetAccountMapByList(accounts []*models.Account) map[int64]*models.Account {
|
||||||
|
accountMap := make(map[int64]*models.Account)
|
||||||
|
|
||||||
|
for i := 0; i < len(accounts); i++ {
|
||||||
|
account := accounts[i]
|
||||||
|
accountMap[account.AccountId] = account
|
||||||
|
}
|
||||||
|
return accountMap
|
||||||
|
}
|
||||||
|
|||||||
@@ -315,3 +315,14 @@ func (s *TransactionCategoryService) DeleteCategory(uid int64, categoryId int64)
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCategoryMapByList returns a transaction category map by a list
|
||||||
|
func (s *TransactionCategoryService) GetCategoryMapByList(categories []*models.TransactionCategory) map[int64]*models.TransactionCategory {
|
||||||
|
categoryMap := make(map[int64]*models.TransactionCategory)
|
||||||
|
|
||||||
|
for i := 0; i < len(categories); i++ {
|
||||||
|
category := categories[i]
|
||||||
|
categoryMap[category.CategoryId] = category
|
||||||
|
}
|
||||||
|
return categoryMap
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ func (s *TransactionTagService) GetMaxDisplayOrder(uid int64) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllTagIdsOfAllTransactions returns all transaction tag ids
|
||||||
|
func (s *TransactionTagService) GetAllTagIdsOfAllTransactions(uid int64) (map[int64][]int64, error) {
|
||||||
|
if uid <= 0 {
|
||||||
|
return nil, errs.ErrUserIdInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagIndexs []*models.TransactionTagIndex
|
||||||
|
err := s.UserDataDB(uid).Where("uid=?", uid).Find(&tagIndexs)
|
||||||
|
|
||||||
|
allTransactionTagIds := s.getGroupedTransactionTagIds(tagIndexs)
|
||||||
|
|
||||||
|
return allTransactionTagIds, err
|
||||||
|
}
|
||||||
|
|
||||||
// GetAllTagIdsOfTransactions returns transaction tag ids for given transactions
|
// GetAllTagIdsOfTransactions returns transaction tag ids for given transactions
|
||||||
func (s *TransactionTagService) GetAllTagIdsOfTransactions(uid int64, transactionIds []int64) (map[int64][]int64, error) {
|
func (s *TransactionTagService) GetAllTagIdsOfTransactions(uid int64, transactionIds []int64) (map[int64][]int64, error) {
|
||||||
if uid <= 0 {
|
if uid <= 0 {
|
||||||
@@ -92,20 +106,7 @@ func (s *TransactionTagService) GetAllTagIdsOfTransactions(uid int64, transactio
|
|||||||
var tagIndexs []*models.TransactionTagIndex
|
var tagIndexs []*models.TransactionTagIndex
|
||||||
err := s.UserDataDB(uid).Where("uid=?", uid).In("transaction_id", transactionIds).Find(&tagIndexs)
|
err := s.UserDataDB(uid).Where("uid=?", uid).In("transaction_id", transactionIds).Find(&tagIndexs)
|
||||||
|
|
||||||
allTransactionTagIds := make(map[int64][]int64)
|
allTransactionTagIds := s.getGroupedTransactionTagIds(tagIndexs)
|
||||||
|
|
||||||
for i := 0; i < len(tagIndexs); i++ {
|
|
||||||
tagIndex := tagIndexs[i]
|
|
||||||
|
|
||||||
var transactionTagIds []int64
|
|
||||||
|
|
||||||
if _, exists := allTransactionTagIds[tagIndex.TransactionId]; exists {
|
|
||||||
transactionTagIds = allTransactionTagIds[tagIndex.TransactionId]
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionTagIds = append(transactionTagIds, tagIndex.TagId)
|
|
||||||
allTransactionTagIds[tagIndex.TransactionId] = transactionTagIds
|
|
||||||
}
|
|
||||||
|
|
||||||
return allTransactionTagIds, err
|
return allTransactionTagIds, err
|
||||||
}
|
}
|
||||||
@@ -251,3 +252,32 @@ func (s *TransactionTagService) ExistsTagName(uid int64, name string) (bool, err
|
|||||||
|
|
||||||
return s.UserDB().Cols("name").Where("uid=? AND name=?", uid, name).Exist(&models.TransactionTag{})
|
return s.UserDB().Cols("name").Where("uid=? AND name=?", uid, name).Exist(&models.TransactionTag{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTagMapByList returns a transaction tag map by a list
|
||||||
|
func (s *TransactionTagService) GetTagMapByList(tags []*models.TransactionTag) map[int64]*models.TransactionTag {
|
||||||
|
tagMap := make(map[int64]*models.TransactionTag)
|
||||||
|
|
||||||
|
for i := 0; i < len(tags); i++ {
|
||||||
|
tag := tags[i]
|
||||||
|
tagMap[tag.TagId] = tag
|
||||||
|
}
|
||||||
|
return tagMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TransactionTagService) getGroupedTransactionTagIds(tagIndexs []*models.TransactionTagIndex) map[int64][]int64 {
|
||||||
|
allTransactionTagIds := make(map[int64][]int64)
|
||||||
|
|
||||||
|
for i := 0; i < len(tagIndexs); i++ {
|
||||||
|
tagIndex := tagIndexs[i]
|
||||||
|
|
||||||
|
var transactionTagIds []int64
|
||||||
|
|
||||||
|
if _, exists := allTransactionTagIds[tagIndex.TransactionId]; exists {
|
||||||
|
transactionTagIds = allTransactionTagIds[tagIndex.TransactionId]
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionTagIds = append(transactionTagIds, tagIndex.TagId)
|
||||||
|
allTransactionTagIds[tagIndex.TransactionId] = transactionTagIds
|
||||||
|
}
|
||||||
|
return allTransactionTagIds
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ type Config struct {
|
|||||||
|
|
||||||
// User
|
// User
|
||||||
EnableUserRegister bool
|
EnableUserRegister bool
|
||||||
|
|
||||||
|
// Data
|
||||||
|
EnableDataExport bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfiguration loads setting config from given config file path
|
// LoadConfiguration loads setting config from given config file path
|
||||||
@@ -214,6 +217,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = loadDataConfiguration(config, cfgFile, "data")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +386,12 @@ func loadUserConfiguration(config *Config, configFile *ini.File, sectionName str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadDataConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||||
|
config.EnableDataExport = getConfigItemBoolValue(configFile, sectionName, "enable_export", false)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getWorkingPath() (string, error) {
|
func getWorkingPath() (string, error) {
|
||||||
workingPath := os.Getenv(labWorkDirEnvName)
|
workingPath := os.Getenv(labWorkDirEnvName)
|
||||||
|
|
||||||
|
|||||||
+34
-4
@@ -11,16 +11,25 @@ import (
|
|||||||
"github.com/mayswind/lab/pkg/errs"
|
"github.com/mayswind/lab/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrintSuccessResult writes success response to current http context
|
// PrintJsonSuccessResult writes success response in json format to current http context
|
||||||
func PrintSuccessResult(c *core.Context, result interface{}) {
|
func PrintJsonSuccessResult(c *core.Context, result interface{}) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"result": result,
|
"result": result,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrintErrorResult writes error response to current http context
|
// PrintDataSuccessResult writes success response in custom content type to current http context
|
||||||
func PrintErrorResult(c *core.Context, err *errs.Error) {
|
func PrintDataSuccessResult(c *core.Context, contentType string, fileName string, result []byte) {
|
||||||
|
if fileName != "" {
|
||||||
|
c.Header("Content-Disposition", "attachment;filename=" + fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, contentType, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintJsonErrorResult writes error response in json format to current http context
|
||||||
|
func PrintJsonErrorResult(c *core.Context, err *errs.Error) {
|
||||||
c.SetResponseError(err)
|
c.SetResponseError(err)
|
||||||
|
|
||||||
errorMessage := err.Error()
|
errorMessage := err.Error()
|
||||||
@@ -44,6 +53,27 @@ func PrintErrorResult(c *core.Context, err *errs.Error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrintDataErrorResult writes error response in custom content type to current http context
|
||||||
|
func PrintDataErrorResult(c *core.Context, contentType string, err *errs.Error) {
|
||||||
|
c.SetResponseError(err)
|
||||||
|
|
||||||
|
errorMessage := err.Error()
|
||||||
|
|
||||||
|
if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 {
|
||||||
|
validationErrors, ok := err.BaseError[0].(validator.ValidationErrors)
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
for _, err := range validationErrors {
|
||||||
|
errorMessage = getValidationErrorText(err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(err.HttpStatusCode, contentType, []byte(errorMessage))
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
|
||||||
func getValidationErrorText(err validator.FieldError) string {
|
func getValidationErrorText(err validator.FieldError) string {
|
||||||
fieldName := GetFirstLowerCharString(err.Field())
|
fieldName := GetFirstLowerCharString(err.Field())
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package utils
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
unixTimeFormat = "1136239445"
|
||||||
longDateTimeFormat = "2006-01-02 15:04:05"
|
longDateTimeFormat = "2006-01-02 15:04:05"
|
||||||
|
longDateTimeWithoutSecondFormat = "2006-01-02 15:04"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormatToLongDateTime returns a textual representation of the time value formatted by long date time format
|
// FormatToLongDateTime returns a textual representation of the time value formatted by long date time format
|
||||||
@@ -11,6 +13,16 @@ func FormatToLongDateTime(t time.Time) string {
|
|||||||
return t.Format(longDateTimeFormat)
|
return t.Format(longDateTimeFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatToLongDateTimeWithoutSecond returns a textual representation of the time value formatted by long date time format (no second)
|
||||||
|
func FormatToLongDateTimeWithoutSecond(t time.Time) string {
|
||||||
|
return t.Format(longDateTimeWithoutSecondFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFromUnixTime parses a unix time and returns a golang time struct
|
||||||
|
func ParseFromUnixTime(unixTime int64) time.Time {
|
||||||
|
return time.Unix(unixTime, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// ParseFromLongDateTime parses a formatted string in long date time format
|
// ParseFromLongDateTime parses a formatted string in long date time format
|
||||||
func ParseFromLongDateTime(t string) (time.Time, error) {
|
func ParseFromLongDateTime(t string) (time.Time, error) {
|
||||||
return time.Parse(longDateTimeFormat, t)
|
return time.Parse(longDateTimeFormat, t)
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ func SubString(str string, start int, length int) string {
|
|||||||
end := 0
|
end := 0
|
||||||
|
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
start = realLength - 1 + start
|
start = realLength + start
|
||||||
}
|
}
|
||||||
|
|
||||||
end = start + length
|
end = start + length
|
||||||
|
|
||||||
if start > end {
|
if start > end {
|
||||||
|
|||||||
+7
-1
@@ -3,10 +3,12 @@ import moment from 'moment';
|
|||||||
import userState from "./userstate.js";
|
import userState from "./userstate.js";
|
||||||
import exchangeRates from "./exchangeRates.js";
|
import exchangeRates from "./exchangeRates.js";
|
||||||
|
|
||||||
|
const baseUrlPath = '/api';
|
||||||
|
|
||||||
let needBlockRequest = false;
|
let needBlockRequest = false;
|
||||||
let blockedRequests = [];
|
let blockedRequests = [];
|
||||||
|
|
||||||
axios.defaults.baseURL = '/api';
|
axios.defaults.baseURL = baseUrlPath;
|
||||||
axios.interceptors.request.use(config => {
|
axios.interceptors.request.use(config => {
|
||||||
const token = userState.getToken();
|
const token = userState.getToken();
|
||||||
|
|
||||||
@@ -121,6 +123,10 @@ export default {
|
|||||||
blockedRequests.length = 0;
|
blockedRequests.length = 0;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
getDataExportUrl: () => {
|
||||||
|
const token = userState.getToken();
|
||||||
|
return `${baseUrlPath}/data/export.csv?token=${token}`;
|
||||||
|
},
|
||||||
getTokens: () => {
|
getTokens: () => {
|
||||||
return axios.get('v1/tokens/list.json');
|
return axios.get('v1/tokens/list.json');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,5 +95,6 @@ export default {
|
|||||||
isEnableAutoDarkMode: () => getOption('autoDarkMode'),
|
isEnableAutoDarkMode: () => getOption('autoDarkMode'),
|
||||||
setEnableAutoDarkMode: value => setOption('autoDarkMode', value),
|
setEnableAutoDarkMode: value => setOption('autoDarkMode', value),
|
||||||
isUserRegistrationEnabled: () => getServerSetting('r') === '1',
|
isUserRegistrationEnabled: () => getServerSetting('r') === '1',
|
||||||
|
isDataExportingEnabled: () => getServerSetting('e') === '1',
|
||||||
clearSettings: clearSettings
|
clearSettings: clearSettings
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -574,6 +574,8 @@ export default {
|
|||||||
'Nothing has been modified': 'Nothing has been modified',
|
'Nothing has been modified': 'Nothing has been modified',
|
||||||
'Your profile has been successfully updated': 'Your profile has been successfully updated',
|
'Your profile has been successfully updated': 'Your profile has been successfully updated',
|
||||||
'Unable to update user profile': 'Unable to update user profile',
|
'Unable to update user profile': 'Unable to update user profile',
|
||||||
|
'Data Management': 'Data Management',
|
||||||
|
'Export Data': 'Export Data',
|
||||||
'Device & Sessions': 'Device & Sessions',
|
'Device & Sessions': 'Device & Sessions',
|
||||||
'Logout All': 'Logout All',
|
'Logout All': 'Logout All',
|
||||||
'Unable to get session list': 'Unable to get session list',
|
'Unable to get session list': 'Unable to get session list',
|
||||||
|
|||||||
@@ -574,6 +574,8 @@ export default {
|
|||||||
'Nothing has been modified': '没有修改的项目',
|
'Nothing has been modified': '没有修改的项目',
|
||||||
'Your profile has been successfully updated': '您的用户信息更新成功',
|
'Your profile has been successfully updated': '您的用户信息更新成功',
|
||||||
'Unable to update user profile': '无法更新用户信息',
|
'Unable to update user profile': '无法更新用户信息',
|
||||||
|
'Data Management': '数据管理',
|
||||||
|
'Export Data': '导出数据',
|
||||||
'Device & Sessions': '设备和会话',
|
'Device & Sessions': '设备和会话',
|
||||||
'Logout All': '注销全部',
|
'Logout All': '注销全部',
|
||||||
'Unable to get session list': '无法获取会话列表',
|
'Unable to get session list': '无法获取会话列表',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import ExchangeRatesPage from "../views/mobile/ExchangeRates.vue";
|
|||||||
import AboutPage from "../views/mobile/About.vue";
|
import AboutPage from "../views/mobile/About.vue";
|
||||||
|
|
||||||
import UserProfilePage from "../views/mobile/users/UserProfile.vue";
|
import UserProfilePage from "../views/mobile/users/UserProfile.vue";
|
||||||
|
import DataManagementPage from "../views/mobile/users/DataManagement.vue";
|
||||||
import TwoFactorAuthPage from "../views/mobile/users/TwoFactorAuth.vue";
|
import TwoFactorAuthPage from "../views/mobile/users/TwoFactorAuth.vue";
|
||||||
import SessionListPage from "../views/mobile/users/SessionList.vue";
|
import SessionListPage from "../views/mobile/users/SessionList.vue";
|
||||||
|
|
||||||
@@ -199,6 +200,11 @@ const routes = [
|
|||||||
component: UserProfilePage,
|
component: UserProfilePage,
|
||||||
beforeEnter: checkLogin
|
beforeEnter: checkLogin
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/user/data/management',
|
||||||
|
component: DataManagementPage,
|
||||||
|
beforeEnter: checkLogin
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/user/2fa',
|
path: '/user/2fa',
|
||||||
component: TwoFactorAuthPage,
|
component: TwoFactorAuthPage,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<f7-list-item :title="$t('User Profile')" link="/user/profile"></f7-list-item>
|
<f7-list-item :title="$t('User Profile')" link="/user/profile"></f7-list-item>
|
||||||
<f7-list-item :title="$t('Transaction Categories')" link="/category/all"></f7-list-item>
|
<f7-list-item :title="$t('Transaction Categories')" link="/category/all"></f7-list-item>
|
||||||
<f7-list-item :title="$t('Transaction Tags')" link="/tag/list"></f7-list-item>
|
<f7-list-item :title="$t('Transaction Tags')" link="/tag/list"></f7-list-item>
|
||||||
|
<f7-list-item :title="$t('Data Management')" link="/user/data/management" v-if="isDataExportingEnabled"></f7-list-item>
|
||||||
<f7-list-item :title="$t('Two-Factor Authentication')" link="/user/2fa"></f7-list-item>
|
<f7-list-item :title="$t('Two-Factor Authentication')" link="/user/2fa"></f7-list-item>
|
||||||
<f7-list-item :title="$t('Device & Sessions')" link="/user/sessions"></f7-list-item>
|
<f7-list-item :title="$t('Device & Sessions')" link="/user/sessions"></f7-list-item>
|
||||||
<f7-list-button :class="{ 'disabled': logouting }" @click="logout">{{ $t('Log Out') }}</f7-list-button>
|
<f7-list-button :class="{ 'disabled': logouting }" @click="logout">{{ $t('Log Out') }}</f7-list-button>
|
||||||
@@ -107,6 +108,9 @@ export default {
|
|||||||
this.exchangeRatesLastUpdateDate = this.getExchangeRatesLastUpdateDate();
|
this.exchangeRatesLastUpdateDate = this.getExchangeRatesLastUpdateDate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isDataExportingEnabled() {
|
||||||
|
return this.$settings.isDataExportingEnabled();
|
||||||
|
},
|
||||||
isAutoUpdateExchangeRatesData: {
|
isAutoUpdateExchangeRatesData: {
|
||||||
get: function () {
|
get: function () {
|
||||||
return this.$settings.isAutoUpdateExchangeRatesData();
|
return this.$settings.isAutoUpdateExchangeRatesData();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<f7-page>
|
||||||
|
<f7-navbar :title="$t('Data Management')" :back-link="$t('Back')"></f7-navbar>
|
||||||
|
|
||||||
|
<f7-card>
|
||||||
|
<f7-card-content class="no-safe-areas" :padding="false">
|
||||||
|
<f7-list>
|
||||||
|
<f7-list-button external no-chevron target="_blank" :link="$services.getDataExportUrl()">{{ $t('Export Data') }}</f7-list-button>
|
||||||
|
</f7-list>
|
||||||
|
</f7-card-content>
|
||||||
|
</f7-card>
|
||||||
|
</f7-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user