feat: enhance plugin configuration with remote port and schema support
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m7s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m20s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m16s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m57s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m56s
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 33s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 2m35s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 55s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 3m7s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m20s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m59s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m16s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m55s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 2m57s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m56s
This commit is contained in:
@@ -2,6 +2,17 @@ package db
|
|||||||
|
|
||||||
import "github.com/gotunnel/pkg/protocol"
|
import "github.com/gotunnel/pkg/protocol"
|
||||||
|
|
||||||
|
// ConfigField 配置字段定义
|
||||||
|
type ConfigField struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Default string `json:"default,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ClientPlugin 客户端已安装的插件
|
// ClientPlugin 客户端已安装的插件
|
||||||
type ClientPlugin struct {
|
type ClientPlugin struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -9,6 +20,8 @@ type ClientPlugin struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Running bool `json:"running"` // 运行状态
|
Running bool `json:"running"` // 运行状态
|
||||||
Config map[string]string `json:"config,omitempty"` // 插件配置
|
Config map[string]string `json:"config,omitempty"` // 插件配置
|
||||||
|
RemotePort int `json:"remote_port,omitempty"`// 远程监听端口
|
||||||
|
ConfigSchema []ConfigField `json:"config_schema,omitempty"` // 配置模式
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client 客户端数据
|
// Client 客户端数据
|
||||||
|
|||||||
@@ -93,16 +93,19 @@ type StorePluginInfo struct {
|
|||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
SignatureURL string `json:"signature_url,omitempty"`
|
SignatureURL string `json:"signature_url,omitempty"`
|
||||||
|
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreInstallRequest 从商店安装插件请求
|
// StoreInstallRequest 从商店安装插件请求
|
||||||
// @Description 从插件商店安装插件到客户端
|
// @Description 从插件商店安装插件到客户端
|
||||||
type StoreInstallRequest struct {
|
type StoreInstallRequest struct {
|
||||||
PluginName string `json:"plugin_name" binding:"required"`
|
PluginName string `json:"plugin_name" binding:"required"`
|
||||||
|
Version string `json:"version"`
|
||||||
DownloadURL string `json:"download_url" binding:"required,url"`
|
DownloadURL string `json:"download_url" binding:"required,url"`
|
||||||
SignatureURL string `json:"signature_url" binding:"required,url"`
|
SignatureURL string `json:"signature_url" binding:"required,url"`
|
||||||
ClientID string `json:"client_id" binding:"required"`
|
ClientID string `json:"client_id" binding:"required"`
|
||||||
RemotePort int `json:"remote_port"`
|
RemotePort int `json:"remote_port"`
|
||||||
|
ConfigSchema []ConfigField `json:"config_schema,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSPluginPushRequest 推送 JS 插件到客户端请求
|
// JSPluginPushRequest 推送 JS 插件到客户端请求
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
// removed router import
|
"github.com/gotunnel/internal/server/db"
|
||||||
"github.com/gotunnel/internal/server/router/dto"
|
"github.com/gotunnel/internal/server/router/dto"
|
||||||
"github.com/gotunnel/pkg/plugin"
|
"github.com/gotunnel/pkg/plugin"
|
||||||
)
|
)
|
||||||
@@ -145,16 +147,42 @@ func (h *PluginHandler) GetClientConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查找客户端的插件
|
||||||
|
var clientPlugin *db.ClientPlugin
|
||||||
|
for i, p := range client.Plugins {
|
||||||
|
if p.Name == pluginName {
|
||||||
|
clientPlugin = &client.Plugins[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientPlugin == nil {
|
||||||
|
NotFound(c, "plugin not installed on client")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaFields []dto.ConfigField
|
||||||
|
|
||||||
|
// 优先使用客户端插件保存的 ConfigSchema
|
||||||
|
if len(clientPlugin.ConfigSchema) > 0 {
|
||||||
|
for _, f := range clientPlugin.ConfigSchema {
|
||||||
|
schemaFields = append(schemaFields, dto.ConfigField{
|
||||||
|
Key: f.Key,
|
||||||
|
Label: f.Label,
|
||||||
|
Type: f.Type,
|
||||||
|
Default: f.Default,
|
||||||
|
Required: f.Required,
|
||||||
|
Options: f.Options,
|
||||||
|
Description: f.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// 尝试从内置插件获取配置模式
|
// 尝试从内置插件获取配置模式
|
||||||
schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName)
|
schema, err := h.app.GetServer().GetPluginConfigSchema(pluginName)
|
||||||
var schemaFields []dto.ConfigField
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 如果内置插件中找不到,尝试从 JS 插件获取
|
// 如果内置插件中找不到,尝试从 JS 插件获取
|
||||||
jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
|
jsPlugin, jsErr := h.app.GetJSPluginStore().GetJSPlugin(pluginName)
|
||||||
if jsErr != nil {
|
if jsErr == nil {
|
||||||
// 两者都找不到,返回空 schema
|
|
||||||
schemaFields = []dto.ConfigField{}
|
|
||||||
} else {
|
|
||||||
// 使用 JS 插件的 config 作为动态 schema
|
// 使用 JS 插件的 config 作为动态 schema
|
||||||
for key := range jsPlugin.Config {
|
for key := range jsPlugin.Config {
|
||||||
schemaFields = append(schemaFields, dto.ConfigField{
|
schemaFields = append(schemaFields, dto.ConfigField{
|
||||||
@@ -167,18 +195,25 @@ func (h *PluginHandler) GetClientConfig(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
schemaFields = convertRouterConfigFields(schema)
|
schemaFields = convertRouterConfigFields(schema)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 查找客户端的插件配置
|
// 添加 remote_port 作为系统配置字段(始终显示)
|
||||||
var config map[string]string
|
schemaFields = append([]dto.ConfigField{{
|
||||||
for _, p := range client.Plugins {
|
Key: "remote_port",
|
||||||
if p.Name == pluginName {
|
Label: "远程端口",
|
||||||
config = p.Config
|
Type: "number",
|
||||||
break
|
Description: "服务端监听端口,修改后需重启插件生效",
|
||||||
}
|
}}, schemaFields...)
|
||||||
}
|
|
||||||
|
// 构建配置值
|
||||||
|
config := clientPlugin.Config
|
||||||
if config == nil {
|
if config == nil {
|
||||||
config = make(map[string]string)
|
config = make(map[string]string)
|
||||||
}
|
}
|
||||||
|
// 将 remote_port 加入配置
|
||||||
|
if clientPlugin.RemotePort > 0 {
|
||||||
|
config["remote_port"] = fmt.Sprintf("%d", clientPlugin.RemotePort)
|
||||||
|
}
|
||||||
|
|
||||||
Success(c, dto.PluginConfigResponse{
|
Success(c, dto.PluginConfigResponse{
|
||||||
PluginName: pluginName,
|
PluginName: pluginName,
|
||||||
@@ -218,8 +253,19 @@ func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
|
|||||||
|
|
||||||
// 更新插件配置
|
// 更新插件配置
|
||||||
found := false
|
found := false
|
||||||
|
portChanged := false
|
||||||
for i, p := range client.Plugins {
|
for i, p := range client.Plugins {
|
||||||
if p.Name == pluginName {
|
if p.Name == pluginName {
|
||||||
|
// 提取 remote_port 并单独处理
|
||||||
|
if portStr, ok := req.Config["remote_port"]; ok {
|
||||||
|
var newPort int
|
||||||
|
fmt.Sscanf(portStr, "%d", &newPort)
|
||||||
|
if newPort > 0 && newPort != client.Plugins[i].RemotePort {
|
||||||
|
client.Plugins[i].RemotePort = newPort
|
||||||
|
portChanged = true
|
||||||
|
}
|
||||||
|
delete(req.Config, "remote_port") // 不保存到 Config map
|
||||||
|
}
|
||||||
client.Plugins[i].Config = req.Config
|
client.Plugins[i].Config = req.Config
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
@@ -242,12 +288,12 @@ func (h *PluginHandler) UpdateClientConfig(c *gin.Context) {
|
|||||||
if online {
|
if online {
|
||||||
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
|
if err := h.app.GetServer().SyncPluginConfigToClient(clientID, pluginName, req.Config); err != nil {
|
||||||
// 配置已保存,但同步失败,返回警告
|
// 配置已保存,但同步失败,返回警告
|
||||||
PartialSuccess(c, gin.H{"status": "partial"}, "config saved but sync failed: "+err.Error())
|
PartialSuccess(c, gin.H{"status": "partial", "port_changed": portChanged}, "config saved but sync failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Success(c, gin.H{"status": "ok"})
|
Success(c, gin.H{"status": "ok", "port_changed": portChanged})
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertConfigFields 转换插件配置字段到 DTO
|
// convertConfigFields 转换插件配置字段到 DTO
|
||||||
|
|||||||
@@ -164,10 +164,29 @@ func (h *StoreHandler) Install(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
|
version := req.Version
|
||||||
|
if version == "" {
|
||||||
|
version = "1.0.0"
|
||||||
|
}
|
||||||
|
// 转换 ConfigSchema
|
||||||
|
var configSchema []db.ConfigField
|
||||||
|
for _, f := range req.ConfigSchema {
|
||||||
|
configSchema = append(configSchema, db.ConfigField{
|
||||||
|
Key: f.Key,
|
||||||
|
Label: f.Label,
|
||||||
|
Type: f.Type,
|
||||||
|
Default: f.Default,
|
||||||
|
Required: f.Required,
|
||||||
|
Options: f.Options,
|
||||||
|
Description: f.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{
|
dbClient.Plugins = append(dbClient.Plugins, db.ClientPlugin{
|
||||||
Name: req.PluginName,
|
Name: req.PluginName,
|
||||||
Version: "1.0.0",
|
Version: version,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
RemotePort: req.RemotePort,
|
||||||
|
ConfigSchema: configSchema,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
h.app.GetClientStore().UpdateClient(dbClient)
|
h.app.GetClientStore().UpdateClient(dbClient)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { get, post, put, del, getToken } from '../config/axios'
|
import { get, post, put, del, getToken } from '../config/axios'
|
||||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions } from '../types'
|
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo, PluginConfigResponse, JSPlugin, RuleSchemasMap, LogEntry, LogStreamOptions, ConfigField } from '../types'
|
||||||
|
|
||||||
// 重新导出 token 管理方法
|
// 重新导出 token 管理方法
|
||||||
export { getToken, setToken, removeToken } from '../config/axios'
|
export { getToken, setToken, removeToken } from '../config/axios'
|
||||||
@@ -49,8 +49,8 @@ export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
|
|||||||
|
|
||||||
// 扩展商店
|
// 扩展商店
|
||||||
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins')
|
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[] }>('/store/plugins')
|
||||||
export const installStorePlugin = (pluginName: string, downloadUrl: string, signatureUrl: string, clientId: string, remotePort?: number) =>
|
export const installStorePlugin = (pluginName: string, downloadUrl: string, signatureUrl: string, clientId: string, remotePort?: number, version?: string, configSchema?: ConfigField[]) =>
|
||||||
post('/store/install', { plugin_name: pluginName, download_url: downloadUrl, signature_url: signatureUrl, client_id: clientId, remote_port: remotePort || 0 })
|
post('/store/install', { plugin_name: pluginName, version: version || '', download_url: downloadUrl, signature_url: signatureUrl, client_id: clientId, remote_port: remotePort || 0, config_schema: configSchema || [] })
|
||||||
|
|
||||||
// 客户端插件配置
|
// 客户端插件配置
|
||||||
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
|
export const getClientPluginConfig = (clientId: string, pluginName: string) =>
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export interface StorePluginInfo {
|
|||||||
icon?: string
|
icon?: string
|
||||||
download_url?: string
|
download_url?: string
|
||||||
signature_url?: string
|
signature_url?: string
|
||||||
|
config_schema?: ConfigField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// JS 插件信息
|
// JS 插件信息
|
||||||
|
|||||||
@@ -86,8 +86,7 @@ const configLoading = ref(false)
|
|||||||
const showStoreModal = ref(false)
|
const showStoreModal = ref(false)
|
||||||
const storePlugins = ref<StorePluginInfo[]>([])
|
const storePlugins = ref<StorePluginInfo[]>([])
|
||||||
const storeLoading = ref(false)
|
const storeLoading = ref(false)
|
||||||
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
|
const storeInstalling = ref<string | null>(null) // 正在安装的插件名称
|
||||||
const storeInstalling = ref(false)
|
|
||||||
|
|
||||||
// 日志查看相关
|
// 日志查看相关
|
||||||
const showLogViewer = ref(false)
|
const showLogViewer = ref(false)
|
||||||
@@ -96,7 +95,6 @@ const showLogViewer = ref(false)
|
|||||||
const openStoreModal = async () => {
|
const openStoreModal = async () => {
|
||||||
showStoreModal.value = true
|
showStoreModal.value = true
|
||||||
storeLoading.value = true
|
storeLoading.value = true
|
||||||
selectedStorePlugin.value = null
|
|
||||||
try {
|
try {
|
||||||
const { data } = await getStorePlugins()
|
const { data } = await getStorePlugins()
|
||||||
storePlugins.value = (data.plugins || []).filter(p => p.download_url)
|
storePlugins.value = (data.plugins || []).filter(p => p.download_url)
|
||||||
@@ -113,18 +111,17 @@ const handleInstallStorePlugin = async (plugin: StorePluginInfo) => {
|
|||||||
message.error('该插件没有下载地址')
|
message.error('该插件没有下载地址')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
storeInstalling.value = true
|
|
||||||
selectedStorePlugin.value = plugin
|
storeInstalling.value = plugin.name
|
||||||
try {
|
try {
|
||||||
await installStorePlugin(plugin.name, plugin.download_url, plugin.signature_url || '', clientId)
|
await installStorePlugin(plugin.name, plugin.download_url, plugin.signature_url || '', clientId, 8080, plugin.version, plugin.config_schema)
|
||||||
message.success(`已安装 ${plugin.name}`)
|
message.success(`已安装 ${plugin.name},可在配置中修改端口和其他设置`)
|
||||||
showStoreModal.value = false
|
showStoreModal.value = false
|
||||||
await loadClient()
|
await loadClient()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.response?.data || '安装失败')
|
message.error(e.response?.data || '安装失败')
|
||||||
} finally {
|
} finally {
|
||||||
storeInstalling.value = false
|
storeInstalling.value = null
|
||||||
selectedStorePlugin.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,7 +721,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
|
|||||||
<n-button
|
<n-button
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="storeInstalling && selectedStorePlugin?.name === plugin.name"
|
:loading="storeInstalling === plugin.name"
|
||||||
@click="handleInstallStorePlugin(plugin)"
|
@click="handleInstallStorePlugin(plugin)"
|
||||||
>
|
>
|
||||||
安装
|
安装
|
||||||
|
|||||||
@@ -230,13 +230,11 @@ const toggleJSPlugin = async (plugin: JSPlugin) => {
|
|||||||
const showInstallModal = ref(false)
|
const showInstallModal = ref(false)
|
||||||
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
|
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
|
||||||
const selectedClientId = ref('')
|
const selectedClientId = ref('')
|
||||||
const storePluginRemotePort = ref<number | null>(8080)
|
|
||||||
const installing = ref(false)
|
const installing = ref(false)
|
||||||
|
|
||||||
const openInstallModal = (plugin: StorePluginInfo) => {
|
const openInstallModal = (plugin: StorePluginInfo) => {
|
||||||
selectedStorePlugin.value = plugin
|
selectedStorePlugin.value = plugin
|
||||||
selectedClientId.value = ''
|
selectedClientId.value = ''
|
||||||
storePluginRemotePort.value = 8080
|
|
||||||
showInstallModal.value = true
|
showInstallModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +258,11 @@ const handleInstallStorePlugin = async () => {
|
|||||||
selectedStorePlugin.value.download_url,
|
selectedStorePlugin.value.download_url,
|
||||||
selectedStorePlugin.value.signature_url,
|
selectedStorePlugin.value.signature_url,
|
||||||
selectedClientId.value,
|
selectedClientId.value,
|
||||||
storePluginRemotePort.value || 0
|
8080, // 默认端口,可在配置中修改
|
||||||
|
selectedStorePlugin.value.version,
|
||||||
|
selectedStorePlugin.value.config_schema
|
||||||
)
|
)
|
||||||
message.success(`已安装 ${selectedStorePlugin.value.name} 到客户端`)
|
message.success(`已安装 ${selectedStorePlugin.value.name},可在客户端配置中修改端口和其他设置`)
|
||||||
showInstallModal.value = false
|
showInstallModal.value = false
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.response?.data || '安装失败')
|
message.error(e.response?.data || '安装失败')
|
||||||
@@ -464,16 +464,7 @@ onMounted(() => {
|
|||||||
placeholder="选择要安装到的客户端"
|
placeholder="选择要安装到的客户端"
|
||||||
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
|
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
|
||||||
/>
|
/>
|
||||||
<div>
|
<p style="margin: 0; color: #999; font-size: 12px;">安装后可在客户端详情页配置端口和其他设置</p>
|
||||||
<p style="margin: 0 0 8px 0; color: #666; font-size: 13px;">远程端口(服务端监听端口):</p>
|
|
||||||
<n-input-number
|
|
||||||
v-model:value="storePluginRemotePort"
|
|
||||||
:min="1"
|
|
||||||
:max="65535"
|
|
||||||
placeholder="输入端口号"
|
|
||||||
style="width: 100%;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</n-space>
|
</n-space>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-space justify="end">
|
<n-space justify="end">
|
||||||
|
|||||||
Reference in New Issue
Block a user