mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-03-15 02:04:46 +08:00
feat: 实现远程数据库改密、授权功能
This commit is contained in:
parent
cb7351a9fb
commit
7f79f5f031
@ -68,7 +68,11 @@ func (b *BaseApi) SearchRemoteDB(c *gin.Context) {
|
|||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Router /databases/remote/list/:type [get]
|
// @Router /databases/remote/list/:type [get]
|
||||||
func (b *BaseApi) ListRemoteDB(c *gin.Context) {
|
func (b *BaseApi) ListRemoteDB(c *gin.Context) {
|
||||||
dbType := c.Query("type")
|
dbType, err := helper.GetStrParamByKey(c, "type")
|
||||||
|
if err != nil {
|
||||||
|
helper.ErrorWithDetail(c, constant.CodeErrBadRequest, constant.ErrTypeInvalidParams, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
list, err := remoteDBService.List(dbType)
|
list, err := remoteDBService.List(dbType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
helper.ErrorWithDetail(c, constant.CodeErrInternalServer, constant.ErrTypeInternalServer, err)
|
||||||
|
@ -6,6 +6,7 @@ type MysqlDBInfo struct {
|
|||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
From string `json:"from"`
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
@ -122,10 +122,15 @@ func (u *MysqlService) Create(ctx context.Context, req dto.MysqlDBCreate) (*mode
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer cli.Close()
|
||||||
if err := cli.Create(client.CreateInfo{
|
if err := cli.Create(client.CreateInfo{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Format: req.Format,
|
Format: req.Format,
|
||||||
Version: version,
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
Permission: req.Permission,
|
||||||
|
Version: version,
|
||||||
|
Timeout: 300,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -168,6 +173,7 @@ func (u *MysqlService) Delete(ctx context.Context, req dto.MysqlDBDelete) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cli.Close()
|
||||||
db, err := mysqlRepo.Get(commonRepo.WithByID(req.ID))
|
db, err := mysqlRepo.Get(commonRepo.WithByID(req.ID))
|
||||||
if err != nil && !req.ForceDelete {
|
if err != nil && !req.ForceDelete {
|
||||||
return err
|
return err
|
||||||
@ -216,6 +222,7 @@ func (u *MysqlService) ChangePassword(info dto.ChangeDBInfo) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cli.Close()
|
||||||
var (
|
var (
|
||||||
mysqlData model.DatabaseMysql
|
mysqlData model.DatabaseMysql
|
||||||
passwordInfo client.PasswordChangeInfo
|
passwordInfo client.PasswordChangeInfo
|
||||||
@ -278,6 +285,7 @@ func (u *MysqlService) ChangeAccess(info dto.ChangeDBInfo) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cli.Close()
|
||||||
var (
|
var (
|
||||||
mysqlData model.DatabaseMysql
|
mysqlData model.DatabaseMysql
|
||||||
accessInfo client.AccessChangeInfo
|
accessInfo client.AccessChangeInfo
|
||||||
@ -293,6 +301,7 @@ func (u *MysqlService) ChangeAccess(info dto.ChangeDBInfo) error {
|
|||||||
}
|
}
|
||||||
accessInfo.Name = mysqlData.Name
|
accessInfo.Name = mysqlData.Name
|
||||||
accessInfo.Username = mysqlData.Username
|
accessInfo.Username = mysqlData.Username
|
||||||
|
accessInfo.Password = mysqlData.Password
|
||||||
accessInfo.OldPermission = mysqlData.Permission
|
accessInfo.OldPermission = mysqlData.Permission
|
||||||
} else {
|
} else {
|
||||||
accessInfo.Username = "root"
|
accessInfo.Username = "root"
|
||||||
@ -544,14 +553,17 @@ func loadClientByID(id uint) (mysql.MysqlClient, string, error) {
|
|||||||
version string
|
version string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dbInfo.From = "local"
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
mysqlData, err = mysqlRepo.Get(commonRepo.WithByID(id))
|
mysqlData, err = mysqlRepo.Get(commonRepo.WithByID(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
dbInfo.From = mysqlData.From
|
||||||
}
|
}
|
||||||
|
|
||||||
if mysqlData.From != "local" {
|
if dbInfo.From != "local" {
|
||||||
databaseItem, err := remoteDBRepo.Get(commonRepo.WithByName(mysqlData.From))
|
databaseItem, err := remoteDBRepo.Get(commonRepo.WithByName(mysqlData.From))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
|
@ -41,7 +41,7 @@ func (u *RemoteDBService) SearchWithPage(search dto.RemoteDBSearch) (int64, inte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *RemoteDBService) List(dbType string) ([]dto.RemoteDBOption, error) {
|
func (u *RemoteDBService) List(dbType string) ([]dto.RemoteDBOption, error) {
|
||||||
dbs, err := remoteDBRepo.GetList(commonRepo.WithByType(dbType), remoteDBRepo.WithoutByFrom("local"))
|
dbs, err := remoteDBRepo.GetList(commonRepo.WithByType(dbType))
|
||||||
var datas []dto.RemoteDBOption
|
var datas []dto.RemoteDBOption
|
||||||
for _, db := range dbs {
|
for _, db := range dbs {
|
||||||
var item dto.RemoteDBOption
|
var item dto.RemoteDBOption
|
||||||
|
@ -437,7 +437,7 @@ func (u *SnapshotService) SnapshotRollback(req dto.SnapshotRecover) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if snap.InterruptStep == "UpdateLiveRestore" {
|
if snap.InterruptStep == "UpdateLiveRestore" {
|
||||||
_, _ = cmd.Exec("systemctl restart dockere")
|
_, _ = cmd.Exec("systemctl restart docker")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ func (s *DatabaseRouter) InitDatabaseRouter(Router *gin.RouterGroup) {
|
|||||||
cmdRouter.POST("/redis/persistence/update", baseApi.UpdateRedisPersistenceConf)
|
cmdRouter.POST("/redis/persistence/update", baseApi.UpdateRedisPersistenceConf)
|
||||||
|
|
||||||
cmdRouter.POST("/remote", baseApi.CreateRemoteDB)
|
cmdRouter.POST("/remote", baseApi.CreateRemoteDB)
|
||||||
cmdRouter.POST("/remote/list/:type", baseApi.ListRemoteDB)
|
cmdRouter.GET("/remote/list/:type", baseApi.ListRemoteDB)
|
||||||
cmdRouter.POST("/remote/update", baseApi.UpdateRemoteDB)
|
cmdRouter.POST("/remote/update", baseApi.UpdateRemoteDB)
|
||||||
cmdRouter.POST("/remote/search", baseApi.SearchRemoteDB)
|
cmdRouter.POST("/remote/search", baseApi.SearchRemoteDB)
|
||||||
cmdRouter.POST("/remote/del", baseApi.DeleteRemoteDB)
|
cmdRouter.POST("/remote/del", baseApi.DeleteRemoteDB)
|
||||||
|
@ -2,7 +2,6 @@ package mysql
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/1Panel-dev/1Panel/backend/buserr"
|
"github.com/1Panel-dev/1Panel/backend/buserr"
|
||||||
@ -22,23 +21,21 @@ type MysqlClient interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewMysqlClient(conn client.DBInfo) (MysqlClient, error) {
|
func NewMysqlClient(conn client.DBInfo) (MysqlClient, error) {
|
||||||
if conn.From == "remote" {
|
|
||||||
connArgs := fmt.Sprintf("%s:%s@tcp(%s:%d)/?charset=utf8", conn.Username, conn.Password, conn.Address, conn.Port)
|
|
||||||
db, err := sql.Open("mysql", connArgs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := db.Ping(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.NewRemote(db), nil
|
|
||||||
}
|
|
||||||
if conn.From == "local" {
|
if conn.From == "local" {
|
||||||
if cmd.CheckIllegal(conn.Address, conn.Username, conn.Password) {
|
if cmd.CheckIllegal(conn.Address, conn.Username, conn.Password) {
|
||||||
return nil, buserr.New(constant.ErrCmdIllegal)
|
return nil, buserr.New(constant.ErrCmdIllegal)
|
||||||
}
|
}
|
||||||
connArgs := []string{"exec", conn.Address, "mysql", "-u" + conn.Username, "-p" + conn.Password + "-e"}
|
connArgs := []string{"exec", conn.Address, "mysql", "-u" + conn.Username, "-p" + conn.Password, "-e"}
|
||||||
return client.NewLocal(connArgs, conn.Address), nil
|
return client.NewLocal(connArgs, conn.Address), nil
|
||||||
}
|
}
|
||||||
return nil, errors.New("no such type")
|
|
||||||
|
connArgs := fmt.Sprintf("%s:%s@tcp(%s:%d)/?charset=utf8", conn.Username, conn.Password, conn.Address, conn.Port)
|
||||||
|
db, err := sql.Open("mysql", connArgs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client.NewRemote(db), nil
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
type DBInfo struct {
|
type DBInfo struct {
|
||||||
From string `json:"from"` // local remote
|
From string `json:"from"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Port uint `json:"port"`
|
Port uint `json:"port"`
|
||||||
Username string `json:"userName"`
|
Username string `json:"userName"`
|
||||||
@ -45,6 +45,7 @@ type AccessChangeInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Username string `json:"userName"`
|
Username string `json:"userName"`
|
||||||
|
Password string `json:"password"`
|
||||||
OldPermission string `json:"oldPermission"`
|
OldPermission string `json:"oldPermission"`
|
||||||
Permission string `json:"permission"`
|
Permission string `json:"permission"`
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ func (r *Local) Create(info CreateInfo) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.CreateUser(CreateInfo{Name: info.Name, Version: info.Version, Username: info.Username, Permission: info.Permission, Timeout: info.Timeout}); err != nil {
|
if err := r.CreateUser(info); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,7 +185,14 @@ func (r *Local) ChangeAccess(info AccessChangeInfo) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := r.CreateUser(CreateInfo{Name: info.Name, Version: info.Version, Username: info.Username, Permission: info.Permission, Timeout: info.Timeout}); err != nil {
|
if err := r.CreateUser(CreateInfo{
|
||||||
|
Name: info.Name,
|
||||||
|
Version: info.Version,
|
||||||
|
Username: info.Username,
|
||||||
|
Password: info.Password,
|
||||||
|
Permission: info.Permission,
|
||||||
|
Timeout: info.Timeout,
|
||||||
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.ExecSQL("flush privileges", 300); err != nil {
|
if err := r.ExecSQL("flush privileges", 300); err != nil {
|
||||||
|
@ -29,7 +29,7 @@ func (r *Remote) Create(info CreateInfo) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.CreateUser(CreateInfo{Name: info.Name, Version: info.Version, Username: info.Username, Permission: info.Permission, Timeout: info.Timeout}); err != nil {
|
if err := r.CreateUser(info); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +183,14 @@ func (r *Remote) ChangeAccess(info AccessChangeInfo) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := r.CreateUser(CreateInfo{Name: info.Name, Version: info.Version, Username: info.Username, Permission: info.Permission, Timeout: info.Timeout}); err != nil {
|
if err := r.CreateUser(CreateInfo{
|
||||||
|
Name: info.Name,
|
||||||
|
Version: info.Version,
|
||||||
|
Username: info.Username,
|
||||||
|
Password: info.Password,
|
||||||
|
Permission: info.Permission,
|
||||||
|
Timeout: info.Timeout,
|
||||||
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.ExecSQL("flush privileges", 300); err != nil {
|
if err := r.ExecSQL("flush privileges", 300); err != nil {
|
||||||
|
@ -9,6 +9,7 @@ export namespace Database {
|
|||||||
id: number;
|
id: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
name: string;
|
name: string;
|
||||||
|
from: string;
|
||||||
format: string;
|
format: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
@ -45,6 +45,18 @@
|
|||||||
/>
|
/>
|
||||||
<span class="input-help">{{ $t('database.remoteHelper') }}</span>
|
<span class="input-help">{{ $t('database.remoteHelper') }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||||
|
<el-select v-model="form.from">
|
||||||
|
<el-option
|
||||||
|
v-for="(item, index) in dbOptions"
|
||||||
|
:key="index"
|
||||||
|
:value="item.name"
|
||||||
|
:label="loadLabel(item)"
|
||||||
|
></el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item :label="$t('commons.table.description')" prop="description">
|
<el-form-item :label="$t('commons.table.description')" prop="description">
|
||||||
<el-input type="textarea" clearable v-model="form.description" />
|
<el-input type="textarea" clearable v-model="form.description" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -71,15 +83,17 @@ import { reactive, ref } from 'vue';
|
|||||||
import { Rules } from '@/global/form-rules';
|
import { Rules } from '@/global/form-rules';
|
||||||
import i18n from '@/lang';
|
import i18n from '@/lang';
|
||||||
import { ElForm } from 'element-plus';
|
import { ElForm } from 'element-plus';
|
||||||
import { addMysqlDB } from '@/api/modules/database';
|
import { addMysqlDB, listRemoteDBs } from '@/api/modules/database';
|
||||||
import DrawerHeader from '@/components/drawer-header/index.vue';
|
import DrawerHeader from '@/components/drawer-header/index.vue';
|
||||||
import { MsgSuccess } from '@/utils/message';
|
import { MsgSuccess } from '@/utils/message';
|
||||||
import { getRandomStr } from '@/utils/util';
|
import { getRandomStr } from '@/utils/util';
|
||||||
|
|
||||||
const loading = ref();
|
const loading = ref();
|
||||||
|
const dbOptions = ref();
|
||||||
const createVisiable = ref(false);
|
const createVisiable = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
|
from: 'local',
|
||||||
mysqlName: '',
|
mysqlName: '',
|
||||||
format: '',
|
format: '',
|
||||||
username: '',
|
username: '',
|
||||||
@ -110,12 +124,22 @@ const acceptParams = (params: DialogProps): void => {
|
|||||||
form.permissionIPs = '';
|
form.permissionIPs = '';
|
||||||
form.description = '';
|
form.description = '';
|
||||||
random();
|
random();
|
||||||
|
loadDBOptions();
|
||||||
createVisiable.value = true;
|
createVisiable.value = true;
|
||||||
};
|
};
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
createVisiable.value = false;
|
createVisiable.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDBOptions = async () => {
|
||||||
|
const res = await listRemoteDBs('mysql');
|
||||||
|
dbOptions.value = res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadLabel(item: any) {
|
||||||
|
return (item.name === 'local' ? i18n.global.t('database.localDB') : item.name) + '(' + item.address + ')';
|
||||||
|
}
|
||||||
|
|
||||||
const random = async () => {
|
const random = async () => {
|
||||||
form.password = getRandomStr(16);
|
form.password = getRandomStr(16);
|
||||||
};
|
};
|
||||||
|
@ -49,6 +49,11 @@
|
|||||||
:class="{ mask: mysqlStatus != 'Running' }"
|
:class="{ mask: mysqlStatus != 'Running' }"
|
||||||
>
|
>
|
||||||
<el-table-column :label="$t('commons.table.name')" prop="name" sortable />
|
<el-table-column :label="$t('commons.table.name')" prop="name" sortable />
|
||||||
|
<el-table-column :label="$t('commons.login.username')" prop="from">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.from === 'local' ? $t('database.localDB') : row.from }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column :label="$t('commons.login.username')" prop="username" />
|
<el-table-column :label="$t('commons.login.username')" prop="username" />
|
||||||
<el-table-column :label="$t('commons.login.password')" prop="password">
|
<el-table-column :label="$t('commons.login.password')" prop="password">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@ -310,6 +315,7 @@ const buttons = [
|
|||||||
click: (row: Database.MysqlDBInfo) => {
|
click: (row: Database.MysqlDBInfo) => {
|
||||||
let param = {
|
let param = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
from: row.from,
|
||||||
mysqlName: row.name,
|
mysqlName: row.name,
|
||||||
operation: 'password',
|
operation: 'password',
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@ -323,6 +329,7 @@ const buttons = [
|
|||||||
click: (row: Database.MysqlDBInfo) => {
|
click: (row: Database.MysqlDBInfo) => {
|
||||||
let param = {
|
let param = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
from: row.from,
|
||||||
mysqlName: row.name,
|
mysqlName: row.name,
|
||||||
operation: 'privilege',
|
operation: 'privilege',
|
||||||
privilege: '',
|
privilege: '',
|
||||||
|
@ -81,6 +81,7 @@ const changeFormRef = ref<FormInstance>();
|
|||||||
const title = ref();
|
const title = ref();
|
||||||
const changeForm = reactive({
|
const changeForm = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
|
from: '',
|
||||||
mysqlName: '',
|
mysqlName: '',
|
||||||
userName: '',
|
userName: '',
|
||||||
password: '',
|
password: '',
|
||||||
@ -93,6 +94,7 @@ const confirmDialogRef = ref();
|
|||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
id: number;
|
id: number;
|
||||||
|
from: string;
|
||||||
mysqlName: string;
|
mysqlName: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -107,6 +109,7 @@ const acceptParams = (params: DialogProps): void => {
|
|||||||
? i18n.global.t('database.changePassword')
|
? i18n.global.t('database.changePassword')
|
||||||
: i18n.global.t('database.permission');
|
: i18n.global.t('database.permission');
|
||||||
changeForm.id = params.id;
|
changeForm.id = params.id;
|
||||||
|
changeForm.from = params.from === 'local' ? 'local' : 'remote';
|
||||||
changeForm.mysqlName = params.mysqlName;
|
changeForm.mysqlName = params.mysqlName;
|
||||||
changeForm.userName = params.username;
|
changeForm.userName = params.username;
|
||||||
changeForm.password = params.password;
|
changeForm.password = params.password;
|
||||||
@ -128,6 +131,7 @@ const submitChangeInfo = async (formEl: FormInstance | undefined) => {
|
|||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
let param = {
|
let param = {
|
||||||
id: changeForm.id,
|
id: changeForm.id,
|
||||||
|
from: changeForm.from,
|
||||||
value: '',
|
value: '',
|
||||||
};
|
};
|
||||||
if (changeForm.operation === 'password') {
|
if (changeForm.operation === 'password') {
|
||||||
@ -177,6 +181,7 @@ const submitChangeInfo = async (formEl: FormInstance | undefined) => {
|
|||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
let param = {
|
let param = {
|
||||||
id: changeForm.id,
|
id: changeForm.id,
|
||||||
|
from: changeForm.from,
|
||||||
value: changeForm.password,
|
value: changeForm.password,
|
||||||
};
|
};
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user