update
This commit is contained in:
@@ -10,8 +10,14 @@ import (
|
||||
|
||||
// ServerConfig 服务端配置
|
||||
type ServerConfig struct {
|
||||
Server ServerSettings `yaml:"server"`
|
||||
Web WebSettings `yaml:"web"`
|
||||
Server ServerSettings `yaml:"server"`
|
||||
Web WebSettings `yaml:"web"`
|
||||
PluginStore PluginStoreSettings `yaml:"plugin_store"`
|
||||
}
|
||||
|
||||
// PluginStoreSettings 扩展商店设置
|
||||
type PluginStoreSettings struct {
|
||||
URL string `yaml:"url"` // 扩展商店URL,例如 GitHub 仓库的 raw URL
|
||||
}
|
||||
|
||||
// ServerSettings 服务端设置
|
||||
|
||||
@@ -2,25 +2,34 @@ package db
|
||||
|
||||
import "github.com/gotunnel/pkg/protocol"
|
||||
|
||||
// ClientPlugin 客户端已安装的插件
|
||||
type ClientPlugin struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Client 客户端数据
|
||||
type Client struct {
|
||||
ID string `json:"id"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Rules []protocol.ProxyRule `json:"rules"`
|
||||
Plugins []ClientPlugin `json:"plugins,omitempty"` // 已安装的插件
|
||||
}
|
||||
|
||||
// PluginData 插件数据
|
||||
type PluginData struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Checksum string `json:"checksum"`
|
||||
Size int64 `json:"size"`
|
||||
Enabled bool `json:"enabled"`
|
||||
WASMData []byte `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Icon string `json:"icon"`
|
||||
Checksum string `json:"checksum"`
|
||||
Size int64 `json:"size"`
|
||||
Enabled bool `json:"enabled"`
|
||||
WASMData []byte `json:"-"`
|
||||
}
|
||||
|
||||
// ClientStore 客户端存储接口
|
||||
|
||||
@@ -39,7 +39,8 @@ func (s *SQLiteStore) init() error {
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
rules TEXT NOT NULL DEFAULT '[]'
|
||||
rules TEXT NOT NULL DEFAULT '[]',
|
||||
plugins TEXT NOT NULL DEFAULT '[]'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -48,6 +49,8 @@ func (s *SQLiteStore) init() error {
|
||||
|
||||
// 迁移:添加 nickname 列
|
||||
s.db.Exec(`ALTER TABLE clients ADD COLUMN nickname TEXT NOT NULL DEFAULT ''`)
|
||||
// 迁移:添加 plugins 列
|
||||
s.db.Exec(`ALTER TABLE clients ADD COLUMN plugins TEXT NOT NULL DEFAULT '[]'`)
|
||||
|
||||
// 创建插件表
|
||||
_, err = s.db.Exec(`
|
||||
@@ -58,13 +61,21 @@ func (s *SQLiteStore) init() error {
|
||||
source TEXT NOT NULL DEFAULT 'wasm',
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
icon TEXT,
|
||||
checksum TEXT,
|
||||
size INTEGER DEFAULT 0,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
wasm_data BLOB
|
||||
)
|
||||
`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 迁移:添加 icon 列
|
||||
s.db.Exec(`ALTER TABLE plugins ADD COLUMN icon TEXT`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
@@ -77,7 +88,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`SELECT id, nickname, rules FROM clients`)
|
||||
rows, err := s.db.Query(`SELECT id, nickname, rules, plugins FROM clients`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -86,13 +97,16 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
|
||||
var clients []Client
|
||||
for rows.Next() {
|
||||
var c Client
|
||||
var rulesJSON string
|
||||
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON); err != nil {
|
||||
var rulesJSON, pluginsJSON string
|
||||
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
||||
c.Rules = []protocol.ProxyRule{}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
|
||||
c.Plugins = []ClientPlugin{}
|
||||
}
|
||||
clients = append(clients, c)
|
||||
}
|
||||
return clients, nil
|
||||
@@ -104,14 +118,17 @@ func (s *SQLiteStore) GetClient(id string) (*Client, error) {
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var c Client
|
||||
var rulesJSON string
|
||||
err := s.db.QueryRow(`SELECT id, nickname, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON)
|
||||
var rulesJSON, pluginsJSON string
|
||||
err := s.db.QueryRow(`SELECT id, nickname, rules, plugins FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
|
||||
c.Rules = []protocol.ProxyRule{}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
|
||||
c.Plugins = []ClientPlugin{}
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
@@ -124,7 +141,12 @@ func (s *SQLiteStore) CreateClient(c *Client) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules) VALUES (?, ?, ?)`, c.ID, c.Nickname, string(rulesJSON))
|
||||
pluginsJSON, err := json.Marshal(c.Plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO clients (id, nickname, rules, plugins) VALUES (?, ?, ?, ?)`,
|
||||
c.ID, c.Nickname, string(rulesJSON), string(pluginsJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -137,7 +159,12 @@ func (s *SQLiteStore) UpdateClient(c *Client) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ? WHERE id = ?`, c.Nickname, string(rulesJSON), c.ID)
|
||||
pluginsJSON, err := json.Marshal(c.Plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE clients SET nickname = ?, rules = ?, plugins = ? WHERE id = ?`,
|
||||
c.Nickname, string(rulesJSON), string(pluginsJSON), c.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -177,7 +204,7 @@ func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) {
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT name, version, type, source, description, author, checksum, size, enabled
|
||||
SELECT name, version, type, source, description, author, icon, checksum, size, enabled
|
||||
FROM plugins
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -189,12 +216,14 @@ func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) {
|
||||
for rows.Next() {
|
||||
var p PluginData
|
||||
var enabled int
|
||||
var icon sql.NullString
|
||||
err := rows.Scan(&p.Name, &p.Version, &p.Type, &p.Source,
|
||||
&p.Description, &p.Author, &p.Checksum, &p.Size, &enabled)
|
||||
&p.Description, &p.Author, &icon, &p.Checksum, &p.Size, &enabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Enabled = enabled == 1
|
||||
p.Icon = icon.String
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
return plugins, nil
|
||||
@@ -207,15 +236,17 @@ func (s *SQLiteStore) GetPlugin(name string) (*PluginData, error) {
|
||||
|
||||
var p PluginData
|
||||
var enabled int
|
||||
var icon sql.NullString
|
||||
err := s.db.QueryRow(`
|
||||
SELECT name, version, type, source, description, author, checksum, size, enabled
|
||||
SELECT name, version, type, source, description, author, icon, checksum, size, enabled
|
||||
FROM plugins WHERE name = ?
|
||||
`, name).Scan(&p.Name, &p.Version, &p.Type, &p.Source,
|
||||
&p.Description, &p.Author, &p.Checksum, &p.Size, &enabled)
|
||||
&p.Description, &p.Author, &icon, &p.Checksum, &p.Size, &enabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Enabled = enabled == 1
|
||||
p.Icon = icon.String
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
@@ -230,10 +261,10 @@ func (s *SQLiteStore) SavePlugin(p *PluginData) error {
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT OR REPLACE INTO plugins
|
||||
(name, version, type, source, description, author, checksum, size, enabled, wasm_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(name, version, type, source, description, author, icon, checksum, size, enabled, wasm_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, p.Name, p.Version, p.Type, p.Source, p.Description, p.Author,
|
||||
p.Checksum, p.Size, enabled, p.WASMData)
|
||||
p.Icon, p.Checksum, p.Size, enabled, p.WASMData)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gotunnel/internal/server/config"
|
||||
"github.com/gotunnel/internal/server/db"
|
||||
@@ -53,6 +55,7 @@ type PluginInfo struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -88,6 +91,7 @@ func RegisterRoutes(r *Router, app AppInterface) {
|
||||
api.HandleFunc("/config/reload", h.handleReload)
|
||||
api.HandleFunc("/plugins", h.handlePlugins)
|
||||
api.HandleFunc("/plugin/", h.handlePlugin)
|
||||
api.HandleFunc("/store/plugins", h.handleStorePlugins)
|
||||
}
|
||||
|
||||
func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -233,7 +237,7 @@ func (h *APIHandler) getClient(rw http.ResponseWriter, clientID string) {
|
||||
online, lastPing := h.server.GetClientStatus(clientID)
|
||||
h.jsonResponse(rw, map[string]interface{}{
|
||||
"id": client.ID, "nickname": client.Nickname, "rules": client.Rules,
|
||||
"online": online, "last_ping": lastPing,
|
||||
"plugins": client.Plugins, "online": online, "last_ping": lastPing,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,6 +245,7 @@ func (h *APIHandler) updateClient(rw http.ResponseWriter, r *http.Request, clien
|
||||
var req struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Rules []protocol.ProxyRule `json:"rules"`
|
||||
Plugins []db.ClientPlugin `json:"plugins"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusBadRequest)
|
||||
@@ -255,6 +260,9 @@ func (h *APIHandler) updateClient(rw http.ResponseWriter, r *http.Request, clien
|
||||
|
||||
client.Nickname = req.Nickname
|
||||
client.Rules = req.Rules
|
||||
if req.Plugins != nil {
|
||||
client.Plugins = req.Plugins
|
||||
}
|
||||
if err := h.clientStore.UpdateClient(client); err != nil {
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -518,3 +526,63 @@ func (h *APIHandler) installPluginsToClient(rw http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
h.jsonResponse(rw, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// StorePluginInfo 扩展商店插件信息
|
||||
type StorePluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
// handleStorePlugins 处理扩展商店插件列表
|
||||
func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := h.app.GetConfig()
|
||||
storeURL := cfg.PluginStore.URL
|
||||
if storeURL == "" {
|
||||
h.jsonResponse(rw, map[string]interface{}{
|
||||
"plugins": []StorePluginInfo{},
|
||||
"store_url": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 从远程URL获取插件列表
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(storeURL)
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to fetch store: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(rw, "Store returned error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(rw, "Failed to read response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var plugins []StorePluginInfo
|
||||
if err := json.Unmarshal(body, &plugins); err != nil {
|
||||
http.Error(rw, "Invalid store format", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h.jsonResponse(rw, map[string]interface{}{
|
||||
"plugins": plugins,
|
||||
"store_url": storeURL,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type PluginMetadata struct {
|
||||
Source PluginSource `json:"source"` // builtin 或 wasm
|
||||
Description string `json:"description"` // 人类可读描述
|
||||
Author string `json:"author"` // Plugin 作者
|
||||
Icon string `json:"icon,omitempty"` // 图标文件名 (如 "socks5.png")
|
||||
Checksum string `json:"checksum,omitempty"` // WASM 二进制的 SHA256
|
||||
Size int64 `json:"size,omitempty"` // WASM 二进制大小
|
||||
Capabilities []string `json:"capabilities,omitempty"` // 所需 host functions
|
||||
|
||||
@@ -65,12 +65,21 @@ type ProxyRule struct {
|
||||
LocalIP string `json:"local_ip" yaml:"local_ip"` // tcp/udp 模式使用
|
||||
LocalPort int `json:"local_port" yaml:"local_port"` // tcp/udp 模式使用
|
||||
RemotePort int `json:"remote_port" yaml:"remote_port"` // 服务端监听端口
|
||||
Enabled *bool `json:"enabled,omitempty" yaml:"enabled"` // 是否启用,默认为 true
|
||||
// Plugin 支持字段
|
||||
PluginName string `json:"plugin_name,omitempty" yaml:"plugin_name"`
|
||||
PluginVersion string `json:"plugin_version,omitempty" yaml:"plugin_version"`
|
||||
PluginConfig map[string]string `json:"plugin_config,omitempty" yaml:"plugin_config"`
|
||||
}
|
||||
|
||||
// IsEnabled 检查规则是否启用,默认为 true
|
||||
func (r *ProxyRule) IsEnabled() bool {
|
||||
if r.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *r.Enabled
|
||||
}
|
||||
|
||||
// ProxyConfig 代理配置下发
|
||||
type ProxyConfig struct {
|
||||
Rules []ProxyRule `json:"rules"`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { get, post, put, del } from '../config/axios'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo } from '../types'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo, StorePluginInfo } from '../types'
|
||||
|
||||
// 重新导出 token 管理方法
|
||||
export { getToken, setToken, removeToken } from '../config/axios'
|
||||
@@ -30,3 +30,6 @@ export const installPluginsToClient = (id: string, plugins: string[]) =>
|
||||
export const getPlugins = () => get<PluginInfo[]>('/plugins')
|
||||
export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`)
|
||||
export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
|
||||
|
||||
// 扩展商店
|
||||
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins')
|
||||
|
||||
@@ -5,6 +5,14 @@ export interface ProxyRule {
|
||||
local_port: number
|
||||
remote_port: number
|
||||
type?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
// 客户端已安装的插件
|
||||
export interface ClientPlugin {
|
||||
name: string
|
||||
version: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 客户端配置
|
||||
@@ -12,6 +20,7 @@ export interface ClientConfig {
|
||||
id: string
|
||||
nickname?: string
|
||||
rules: ProxyRule[]
|
||||
plugins?: ClientPlugin[]
|
||||
}
|
||||
|
||||
// 客户端状态
|
||||
@@ -28,6 +37,7 @@ export interface ClientDetail {
|
||||
id: string
|
||||
nickname?: string
|
||||
rules: ProxyRule[]
|
||||
plugins?: ClientPlugin[]
|
||||
online: boolean
|
||||
last_ping?: string
|
||||
}
|
||||
@@ -58,5 +68,17 @@ export interface PluginInfo {
|
||||
type: string
|
||||
description: string
|
||||
source: string
|
||||
icon?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 扩展商店插件信息
|
||||
export interface StorePluginInfo {
|
||||
name: string
|
||||
version: string
|
||||
type: string
|
||||
description: string
|
||||
author: string
|
||||
icon?: string
|
||||
download_url?: string
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard, NButton, NSpace, NTag, NTable, NEmpty,
|
||||
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox,
|
||||
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NSwitch,
|
||||
NIcon, useMessage, useDialog
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DownloadOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getPlugins, installPluginsToClient } from '../api'
|
||||
import type { ProxyRule, PluginInfo } from '../types'
|
||||
import type { ProxyRule, PluginInfo, ClientPlugin } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -24,6 +24,7 @@ const online = ref(false)
|
||||
const lastPing = ref('')
|
||||
const nickname = ref('')
|
||||
const rules = ref<ProxyRule[]>([])
|
||||
const clientPlugins = ref<ClientPlugin[]>([])
|
||||
const editing = ref(false)
|
||||
const editNickname = ref('')
|
||||
const editRules = ref<ProxyRule[]>([])
|
||||
@@ -68,6 +69,7 @@ const loadClient = async () => {
|
||||
lastPing.value = data.last_ping || ''
|
||||
nickname.value = data.nickname || ''
|
||||
rules.value = data.rules || []
|
||||
clientPlugins.value = data.plugins || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load client', e)
|
||||
}
|
||||
@@ -79,7 +81,8 @@ const startEdit = () => {
|
||||
editNickname.value = nickname.value
|
||||
editRules.value = rules.value.map(rule => ({
|
||||
...rule,
|
||||
type: rule.type || 'tcp'
|
||||
type: rule.type || 'tcp',
|
||||
enabled: rule.enabled !== false
|
||||
}))
|
||||
editing.value = true
|
||||
}
|
||||
@@ -90,7 +93,7 @@ const cancelEdit = () => {
|
||||
|
||||
const addRule = () => {
|
||||
editRules.value.push({
|
||||
name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080, type: 'tcp'
|
||||
name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080, type: 'tcp', enabled: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,6 +170,25 @@ const installPlugins = async () => {
|
||||
message.error(e.response?.data || '安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleClientPlugin = async (plugin: ClientPlugin) => {
|
||||
const newEnabled = !plugin.enabled
|
||||
const updatedPlugins = clientPlugins.value.map(p =>
|
||||
p.name === plugin.name ? { ...p, enabled: newEnabled } : p
|
||||
)
|
||||
try {
|
||||
await updateClient(clientId, {
|
||||
id: clientId,
|
||||
nickname: nickname.value,
|
||||
rules: rules.value,
|
||||
plugins: updatedPlugins
|
||||
})
|
||||
plugin.enabled = newEnabled
|
||||
message.success(newEnabled ? `已启用 ${plugin.name}` : `已禁用 ${plugin.name}`)
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -242,6 +264,7 @@ const installPlugins = async () => {
|
||||
<th>本地地址</th>
|
||||
<th>远程端口</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -250,6 +273,11 @@ const installPlugins = async () => {
|
||||
<td>{{ rule.local_ip }}:{{ rule.local_port }}</td>
|
||||
<td>{{ rule.remote_port }}</td>
|
||||
<td><n-tag size="small">{{ rule.type || 'tcp' }}</n-tag></td>
|
||||
<td>
|
||||
<n-tag size="small" :type="rule.enabled !== false ? 'success' : 'default'">
|
||||
{{ rule.enabled !== false ? '启用' : '禁用' }}
|
||||
</n-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
@@ -263,6 +291,9 @@ const installPlugins = async () => {
|
||||
</n-form-item>
|
||||
<n-card v-for="(rule, i) in editRules" :key="i" size="small">
|
||||
<n-space align="center">
|
||||
<n-form-item label="启用" :show-feedback="false">
|
||||
<n-switch v-model:value="rule.enabled" />
|
||||
</n-form-item>
|
||||
<n-form-item label="名称" :show-feedback="false">
|
||||
<n-input v-model:value="rule.name" placeholder="规则名称" />
|
||||
</n-form-item>
|
||||
@@ -291,6 +322,29 @@ const installPlugins = async () => {
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<!-- 已安装插件卡片 -->
|
||||
<n-card title="已安装扩展" style="margin-top: 16px;">
|
||||
<n-empty v-if="clientPlugins.length === 0" description="暂无已安装扩展" />
|
||||
<n-table v-else :bordered="false" :single-line="false">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>版本</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="plugin in clientPlugins" :key="plugin.name">
|
||||
<td>{{ plugin.name }}</td>
|
||||
<td>v{{ plugin.version }}</td>
|
||||
<td>
|
||||
<n-switch :value="plugin.enabled" @update:value="toggleClientPlugin(plugin)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
</n-card>
|
||||
|
||||
<!-- 安装插件模态框 -->
|
||||
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件到客户端" style="width: 500px;">
|
||||
<n-empty v-if="availablePlugins.length === 0" description="暂无可用插件" />
|
||||
|
||||
@@ -3,16 +3,20 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
|
||||
NEmpty, NSpin, NIcon, NSwitch, useMessage
|
||||
NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage
|
||||
} from 'naive-ui'
|
||||
import { ArrowBackOutline, ExtensionPuzzleOutline } from '@vicons/ionicons5'
|
||||
import { getPlugins, enablePlugin, disablePlugin } from '../api'
|
||||
import type { PluginInfo } from '../types'
|
||||
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline } from '@vicons/ionicons5'
|
||||
import { getPlugins, enablePlugin, disablePlugin, getStorePlugins } from '../api'
|
||||
import type { PluginInfo, StorePluginInfo } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const plugins = ref<PluginInfo[]>([])
|
||||
const storePlugins = ref<StorePluginInfo[]>([])
|
||||
const storeUrl = ref('')
|
||||
const loading = ref(true)
|
||||
const storeLoading = ref(false)
|
||||
const activeTab = ref('installed')
|
||||
|
||||
const loadPlugins = async () => {
|
||||
try {
|
||||
@@ -25,6 +29,19 @@ const loadPlugins = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadStorePlugins = async () => {
|
||||
storeLoading.value = true
|
||||
try {
|
||||
const { data } = await getStorePlugins()
|
||||
storePlugins.value = data.plugins || []
|
||||
storeUrl.value = data.store_url || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to load store plugins', e)
|
||||
} finally {
|
||||
storeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const proxyPlugins = computed(() =>
|
||||
plugins.value.filter(p => p.type === 'proxy')
|
||||
)
|
||||
@@ -68,6 +85,12 @@ const getTypeColor = (type: string) => {
|
||||
return colors[type] || 'default'
|
||||
}
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
if (tab === 'store' && storePlugins.value.length === 0) {
|
||||
loadStorePlugins()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPlugins)
|
||||
</script>
|
||||
|
||||
@@ -75,8 +98,8 @@ onMounted(loadPlugins)
|
||||
<div class="plugins-view">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||
<div>
|
||||
<h2 style="margin: 0 0 8px 0;">插件管理</h2>
|
||||
<p style="margin: 0; color: #666;">查看和管理已注册的插件</p>
|
||||
<h2 style="margin: 0 0 8px 0;">扩展商店</h2>
|
||||
<p style="margin: 0; color: #666;">管理已安装扩展和浏览扩展商店</p>
|
||||
</div>
|
||||
<n-button quaternary @click="router.push('/')">
|
||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||
@@ -84,54 +107,92 @@ onMounted(loadPlugins)
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="总插件数" :value="plugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="协议插件" :value="proxyPlugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="应用插件" :value="appPlugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-tabs v-model:value="activeTab" type="line" @update:value="handleTabChange">
|
||||
<!-- 已安装扩展 -->
|
||||
<n-tab-pane name="installed" tab="已安装">
|
||||
<n-spin :show="loading">
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="总插件数" :value="plugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="协议插件" :value="proxyPlugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="应用插件" :value="appPlugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-empty v-if="!loading && plugins.length === 0" description="暂无插件" />
|
||||
<n-empty v-if="!loading && plugins.length === 0" description="暂无已安装扩展" />
|
||||
|
||||
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
||||
<n-gi v-for="plugin in plugins" :key="plugin.name">
|
||||
<n-card hoverable>
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<n-icon size="24" color="#18a058"><ExtensionPuzzleOutline /></n-icon>
|
||||
<span>{{ plugin.name }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-switch :value="plugin.enabled" @update:value="togglePlugin(plugin)" />
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space>
|
||||
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||
<n-tag size="small" :type="getTypeColor(plugin.type)">
|
||||
{{ getTypeLabel(plugin.type) }}
|
||||
</n-tag>
|
||||
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'warning'">
|
||||
{{ plugin.source === 'builtin' ? '内置' : 'WASM' }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
||||
<n-gi v-for="plugin in plugins" :key="plugin.name">
|
||||
<n-card hoverable>
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<img v-if="plugin.icon" :src="plugin.icon" style="width: 24px; height: 24px;" />
|
||||
<n-icon v-else size="24" color="#18a058"><ExtensionPuzzleOutline /></n-icon>
|
||||
<span>{{ plugin.name }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-switch :value="plugin.enabled" @update:value="togglePlugin(plugin)" />
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space>
|
||||
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||
<n-tag size="small" :type="getTypeColor(plugin.type)">
|
||||
{{ getTypeLabel(plugin.type) }}
|
||||
</n-tag>
|
||||
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'warning'">
|
||||
{{ plugin.source === 'builtin' ? '内置' : 'WASM' }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 扩展商店 -->
|
||||
<n-tab-pane name="store" tab="扩展商店">
|
||||
<n-spin :show="storeLoading">
|
||||
<n-empty v-if="!storeUrl" description="未配置扩展商店URL,请在配置文件中设置 plugin_store.url" />
|
||||
<n-empty v-else-if="!storeLoading && storePlugins.length === 0" description="扩展商店暂无可用扩展" />
|
||||
|
||||
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
||||
<n-gi v-for="plugin in storePlugins" :key="plugin.name">
|
||||
<n-card hoverable>
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<img v-if="plugin.icon" :src="plugin.icon" style="width: 24px; height: 24px;" />
|
||||
<n-icon v-else size="24" color="#18a058"><StorefrontOutline /></n-icon>
|
||||
<span>{{ plugin.name }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space>
|
||||
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||
<n-tag size="small" :type="getTypeColor(plugin.type)">
|
||||
{{ getTypeLabel(plugin.type) }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
|
||||
<p style="margin: 0; color: #999; font-size: 12px;">作者: {{ plugin.author }}</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user