This commit is contained in:
Flik
2025-12-27 22:49:07 +08:00
parent 622f30b381
commit 4b71ff1604
10 changed files with 351 additions and 87 deletions

View File

@@ -12,6 +12,12 @@ import (
type ServerConfig struct { type ServerConfig struct {
Server ServerSettings `yaml:"server"` Server ServerSettings `yaml:"server"`
Web WebSettings `yaml:"web"` Web WebSettings `yaml:"web"`
PluginStore PluginStoreSettings `yaml:"plugin_store"`
}
// PluginStoreSettings 扩展商店设置
type PluginStoreSettings struct {
URL string `yaml:"url"` // 扩展商店URL例如 GitHub 仓库的 raw URL
} }
// ServerSettings 服务端设置 // ServerSettings 服务端设置

View File

@@ -2,11 +2,19 @@ package db
import "github.com/gotunnel/pkg/protocol" import "github.com/gotunnel/pkg/protocol"
// ClientPlugin 客户端已安装的插件
type ClientPlugin struct {
Name string `json:"name"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
}
// Client 客户端数据 // Client 客户端数据
type Client struct { type Client struct {
ID string `json:"id"` ID string `json:"id"`
Nickname string `json:"nickname,omitempty"` Nickname string `json:"nickname,omitempty"`
Rules []protocol.ProxyRule `json:"rules"` Rules []protocol.ProxyRule `json:"rules"`
Plugins []ClientPlugin `json:"plugins,omitempty"` // 已安装的插件
} }
// PluginData 插件数据 // PluginData 插件数据
@@ -17,6 +25,7 @@ type PluginData struct {
Source string `json:"source"` Source string `json:"source"`
Description string `json:"description"` Description string `json:"description"`
Author string `json:"author"` Author string `json:"author"`
Icon string `json:"icon"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
Size int64 `json:"size"` Size int64 `json:"size"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`

View File

@@ -39,7 +39,8 @@ func (s *SQLiteStore) init() error {
CREATE TABLE IF NOT EXISTS clients ( CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
nickname TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '',
rules TEXT NOT NULL DEFAULT '[]' rules TEXT NOT NULL DEFAULT '[]',
plugins TEXT NOT NULL DEFAULT '[]'
) )
`) `)
if err != nil { if err != nil {
@@ -48,6 +49,8 @@ func (s *SQLiteStore) init() error {
// 迁移:添加 nickname 列 // 迁移:添加 nickname 列
s.db.Exec(`ALTER TABLE clients ADD COLUMN nickname TEXT NOT NULL DEFAULT ''`) 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(` _, err = s.db.Exec(`
@@ -58,13 +61,21 @@ func (s *SQLiteStore) init() error {
source TEXT NOT NULL DEFAULT 'wasm', source TEXT NOT NULL DEFAULT 'wasm',
description TEXT, description TEXT,
author TEXT, author TEXT,
icon TEXT,
checksum TEXT, checksum TEXT,
size INTEGER DEFAULT 0, size INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1,
wasm_data BLOB wasm_data BLOB
) )
`) `)
if err != nil {
return err return err
}
// 迁移:添加 icon 列
s.db.Exec(`ALTER TABLE plugins ADD COLUMN icon TEXT`)
return nil
} }
// Close 关闭数据库连接 // Close 关闭数据库连接
@@ -77,7 +88,7 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() 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 { if err != nil {
return nil, err return nil, err
} }
@@ -86,13 +97,16 @@ func (s *SQLiteStore) GetAllClients() ([]Client, error) {
var clients []Client var clients []Client
for rows.Next() { for rows.Next() {
var c Client var c Client
var rulesJSON string var rulesJSON, pluginsJSON string
if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON); err != nil { if err := rows.Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON); err != nil {
return nil, err return nil, err
} }
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil { if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
c.Rules = []protocol.ProxyRule{} c.Rules = []protocol.ProxyRule{}
} }
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
c.Plugins = []ClientPlugin{}
}
clients = append(clients, c) clients = append(clients, c)
} }
return clients, nil return clients, nil
@@ -104,14 +118,17 @@ func (s *SQLiteStore) GetClient(id string) (*Client, error) {
defer s.mu.RUnlock() defer s.mu.RUnlock()
var c Client var c Client
var rulesJSON string var rulesJSON, pluginsJSON string
err := s.db.QueryRow(`SELECT id, nickname, rules FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON) err := s.db.QueryRow(`SELECT id, nickname, rules, plugins FROM clients WHERE id = ?`, id).Scan(&c.ID, &c.Nickname, &rulesJSON, &pluginsJSON)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil { if err := json.Unmarshal([]byte(rulesJSON), &c.Rules); err != nil {
c.Rules = []protocol.ProxyRule{} c.Rules = []protocol.ProxyRule{}
} }
if err := json.Unmarshal([]byte(pluginsJSON), &c.Plugins); err != nil {
c.Plugins = []ClientPlugin{}
}
return &c, nil return &c, nil
} }
@@ -124,7 +141,12 @@ func (s *SQLiteStore) CreateClient(c *Client) error {
if err != nil { if err != nil {
return err 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 return err
} }
@@ -137,7 +159,12 @@ func (s *SQLiteStore) UpdateClient(c *Client) error {
if err != nil { if err != nil {
return err 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 return err
} }
@@ -177,7 +204,7 @@ func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) {
defer s.mu.RUnlock() defer s.mu.RUnlock()
rows, err := s.db.Query(` 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 FROM plugins
`) `)
if err != nil { if err != nil {
@@ -189,12 +216,14 @@ func (s *SQLiteStore) GetAllPlugins() ([]PluginData, error) {
for rows.Next() { for rows.Next() {
var p PluginData var p PluginData
var enabled int var enabled int
var icon sql.NullString
err := rows.Scan(&p.Name, &p.Version, &p.Type, &p.Source, 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 { if err != nil {
return nil, err return nil, err
} }
p.Enabled = enabled == 1 p.Enabled = enabled == 1
p.Icon = icon.String
plugins = append(plugins, p) plugins = append(plugins, p)
} }
return plugins, nil return plugins, nil
@@ -207,15 +236,17 @@ func (s *SQLiteStore) GetPlugin(name string) (*PluginData, error) {
var p PluginData var p PluginData
var enabled int var enabled int
var icon sql.NullString
err := s.db.QueryRow(` 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 = ? FROM plugins WHERE name = ?
`, name).Scan(&p.Name, &p.Version, &p.Type, &p.Source, `, 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 { if err != nil {
return nil, err return nil, err
} }
p.Enabled = enabled == 1 p.Enabled = enabled == 1
p.Icon = icon.String
return &p, nil return &p, nil
} }
@@ -230,10 +261,10 @@ func (s *SQLiteStore) SavePlugin(p *PluginData) error {
} }
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT OR REPLACE INTO plugins INSERT OR REPLACE INTO plugins
(name, version, type, source, description, author, checksum, size, enabled, wasm_data) (name, version, type, source, description, author, icon, checksum, size, enabled, wasm_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, p.Name, p.Version, p.Type, p.Source, p.Description, p.Author, `, 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 return err
} }

View File

@@ -2,8 +2,10 @@ package router
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"regexp" "regexp"
"time"
"github.com/gotunnel/internal/server/config" "github.com/gotunnel/internal/server/config"
"github.com/gotunnel/internal/server/db" "github.com/gotunnel/internal/server/db"
@@ -53,6 +55,7 @@ type PluginInfo struct {
Type string `json:"type"` Type string `json:"type"`
Description string `json:"description"` Description string `json:"description"`
Source string `json:"source"` Source string `json:"source"`
Icon string `json:"icon,omitempty"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
} }
@@ -88,6 +91,7 @@ func RegisterRoutes(r *Router, app AppInterface) {
api.HandleFunc("/config/reload", h.handleReload) api.HandleFunc("/config/reload", h.handleReload)
api.HandleFunc("/plugins", h.handlePlugins) api.HandleFunc("/plugins", h.handlePlugins)
api.HandleFunc("/plugin/", h.handlePlugin) api.HandleFunc("/plugin/", h.handlePlugin)
api.HandleFunc("/store/plugins", h.handleStorePlugins)
} }
func (h *APIHandler) handleStatus(rw http.ResponseWriter, r *http.Request) { 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) online, lastPing := h.server.GetClientStatus(clientID)
h.jsonResponse(rw, map[string]interface{}{ h.jsonResponse(rw, map[string]interface{}{
"id": client.ID, "nickname": client.Nickname, "rules": client.Rules, "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 { var req struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Rules []protocol.ProxyRule `json:"rules"` Rules []protocol.ProxyRule `json:"rules"`
Plugins []db.ClientPlugin `json:"plugins"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest) 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.Nickname = req.Nickname
client.Rules = req.Rules client.Rules = req.Rules
if req.Plugins != nil {
client.Plugins = req.Plugins
}
if err := h.clientStore.UpdateClient(client); err != nil { if err := h.clientStore.UpdateClient(client); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return
@@ -518,3 +526,63 @@ func (h *APIHandler) installPluginsToClient(rw http.ResponseWriter, r *http.Requ
} }
h.jsonResponse(rw, map[string]string{"status": "ok"}) 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,
})
}

View File

@@ -31,6 +31,7 @@ type PluginMetadata struct {
Source PluginSource `json:"source"` // builtin 或 wasm Source PluginSource `json:"source"` // builtin 或 wasm
Description string `json:"description"` // 人类可读描述 Description string `json:"description"` // 人类可读描述
Author string `json:"author"` // Plugin 作者 Author string `json:"author"` // Plugin 作者
Icon string `json:"icon,omitempty"` // 图标文件名 (如 "socks5.png")
Checksum string `json:"checksum,omitempty"` // WASM 二进制的 SHA256 Checksum string `json:"checksum,omitempty"` // WASM 二进制的 SHA256
Size int64 `json:"size,omitempty"` // WASM 二进制大小 Size int64 `json:"size,omitempty"` // WASM 二进制大小
Capabilities []string `json:"capabilities,omitempty"` // 所需 host functions Capabilities []string `json:"capabilities,omitempty"` // 所需 host functions

View File

@@ -65,12 +65,21 @@ type ProxyRule struct {
LocalIP string `json:"local_ip" yaml:"local_ip"` // tcp/udp 模式使用 LocalIP string `json:"local_ip" yaml:"local_ip"` // tcp/udp 模式使用
LocalPort int `json:"local_port" yaml:"local_port"` // tcp/udp 模式使用 LocalPort int `json:"local_port" yaml:"local_port"` // tcp/udp 模式使用
RemotePort int `json:"remote_port" yaml:"remote_port"` // 服务端监听端口 RemotePort int `json:"remote_port" yaml:"remote_port"` // 服务端监听端口
Enabled *bool `json:"enabled,omitempty" yaml:"enabled"` // 是否启用,默认为 true
// Plugin 支持字段 // Plugin 支持字段
PluginName string `json:"plugin_name,omitempty" yaml:"plugin_name"` PluginName string `json:"plugin_name,omitempty" yaml:"plugin_name"`
PluginVersion string `json:"plugin_version,omitempty" yaml:"plugin_version"` PluginVersion string `json:"plugin_version,omitempty" yaml:"plugin_version"`
PluginConfig map[string]string `json:"plugin_config,omitempty" yaml:"plugin_config"` 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 代理配置下发 // ProxyConfig 代理配置下发
type ProxyConfig struct { type ProxyConfig struct {
Rules []ProxyRule `json:"rules"` Rules []ProxyRule `json:"rules"`

View File

@@ -1,5 +1,5 @@
import { get, post, put, del } from '../config/axios' 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 管理方法 // 重新导出 token 管理方法
export { getToken, setToken, removeToken } from '../config/axios' 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 getPlugins = () => get<PluginInfo[]>('/plugins')
export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`) export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`)
export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`) export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
// 扩展商店
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins')

View File

@@ -5,6 +5,14 @@ export interface ProxyRule {
local_port: number local_port: number
remote_port: number remote_port: number
type?: string type?: string
enabled?: boolean
}
// 客户端已安装的插件
export interface ClientPlugin {
name: string
version: string
enabled: boolean
} }
// 客户端配置 // 客户端配置
@@ -12,6 +20,7 @@ export interface ClientConfig {
id: string id: string
nickname?: string nickname?: string
rules: ProxyRule[] rules: ProxyRule[]
plugins?: ClientPlugin[]
} }
// 客户端状态 // 客户端状态
@@ -28,6 +37,7 @@ export interface ClientDetail {
id: string id: string
nickname?: string nickname?: string
rules: ProxyRule[] rules: ProxyRule[]
plugins?: ClientPlugin[]
online: boolean online: boolean
last_ping?: string last_ping?: string
} }
@@ -58,5 +68,17 @@ export interface PluginInfo {
type: string type: string
description: string description: string
source: string source: string
icon?: string
enabled: boolean enabled: boolean
} }
// 扩展商店插件信息
export interface StorePluginInfo {
name: string
version: string
type: string
description: string
author: string
icon?: string
download_url?: string
}

View File

@@ -3,7 +3,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
NCard, NButton, NSpace, NTag, NTable, NEmpty, NCard, NButton, NSpace, NTag, NTable, NEmpty,
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NSwitch,
NIcon, useMessage, useDialog NIcon, useMessage, useDialog
} from 'naive-ui' } from 'naive-ui'
import { import {
@@ -12,7 +12,7 @@ import {
DownloadOutline DownloadOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getPlugins, installPluginsToClient } from '../api' 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 route = useRoute()
const router = useRouter() const router = useRouter()
@@ -24,6 +24,7 @@ const online = ref(false)
const lastPing = ref('') const lastPing = ref('')
const nickname = ref('') const nickname = ref('')
const rules = ref<ProxyRule[]>([]) const rules = ref<ProxyRule[]>([])
const clientPlugins = ref<ClientPlugin[]>([])
const editing = ref(false) const editing = ref(false)
const editNickname = ref('') const editNickname = ref('')
const editRules = ref<ProxyRule[]>([]) const editRules = ref<ProxyRule[]>([])
@@ -68,6 +69,7 @@ const loadClient = async () => {
lastPing.value = data.last_ping || '' lastPing.value = data.last_ping || ''
nickname.value = data.nickname || '' nickname.value = data.nickname || ''
rules.value = data.rules || [] rules.value = data.rules || []
clientPlugins.value = data.plugins || []
} catch (e) { } catch (e) {
console.error('Failed to load client', e) console.error('Failed to load client', e)
} }
@@ -79,7 +81,8 @@ const startEdit = () => {
editNickname.value = nickname.value editNickname.value = nickname.value
editRules.value = rules.value.map(rule => ({ editRules.value = rules.value.map(rule => ({
...rule, ...rule,
type: rule.type || 'tcp' type: rule.type || 'tcp',
enabled: rule.enabled !== false
})) }))
editing.value = true editing.value = true
} }
@@ -90,7 +93,7 @@ const cancelEdit = () => {
const addRule = () => { const addRule = () => {
editRules.value.push({ 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 || '安装失败') 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> </script>
<template> <template>
@@ -242,6 +264,7 @@ const installPlugins = async () => {
<th>本地地址</th> <th>本地地址</th>
<th>远程端口</th> <th>远程端口</th>
<th>类型</th> <th>类型</th>
<th>状态</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -250,6 +273,11 @@ const installPlugins = async () => {
<td>{{ rule.local_ip }}:{{ rule.local_port }}</td> <td>{{ rule.local_ip }}:{{ rule.local_port }}</td>
<td>{{ rule.remote_port }}</td> <td>{{ rule.remote_port }}</td>
<td><n-tag size="small">{{ rule.type || 'tcp' }}</n-tag></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> </tr>
</tbody> </tbody>
</n-table> </n-table>
@@ -263,6 +291,9 @@ const installPlugins = async () => {
</n-form-item> </n-form-item>
<n-card v-for="(rule, i) in editRules" :key="i" size="small"> <n-card v-for="(rule, i) in editRules" :key="i" size="small">
<n-space align="center"> <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-form-item label="名称" :show-feedback="false">
<n-input v-model:value="rule.name" placeholder="规则名称" /> <n-input v-model:value="rule.name" placeholder="规则名称" />
</n-form-item> </n-form-item>
@@ -291,6 +322,29 @@ const installPlugins = async () => {
</template> </template>
</n-card> </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-modal v-model:show="showInstallModal" preset="card" title="安装插件到客户端" style="width: 500px;">
<n-empty v-if="availablePlugins.length === 0" description="暂无可用插件" /> <n-empty v-if="availablePlugins.length === 0" description="暂无可用插件" />

View File

@@ -3,16 +3,20 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
NEmpty, NSpin, NIcon, NSwitch, useMessage NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage
} from 'naive-ui' } from 'naive-ui'
import { ArrowBackOutline, ExtensionPuzzleOutline } from '@vicons/ionicons5' import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline } from '@vicons/ionicons5'
import { getPlugins, enablePlugin, disablePlugin } from '../api' import { getPlugins, enablePlugin, disablePlugin, getStorePlugins } from '../api'
import type { PluginInfo } from '../types' import type { PluginInfo, StorePluginInfo } from '../types'
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const plugins = ref<PluginInfo[]>([]) const plugins = ref<PluginInfo[]>([])
const storePlugins = ref<StorePluginInfo[]>([])
const storeUrl = ref('')
const loading = ref(true) const loading = ref(true)
const storeLoading = ref(false)
const activeTab = ref('installed')
const loadPlugins = async () => { const loadPlugins = async () => {
try { 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(() => const proxyPlugins = computed(() =>
plugins.value.filter(p => p.type === 'proxy') plugins.value.filter(p => p.type === 'proxy')
) )
@@ -68,6 +85,12 @@ const getTypeColor = (type: string) => {
return colors[type] || 'default' return colors[type] || 'default'
} }
const handleTabChange = (tab: string) => {
if (tab === 'store' && storePlugins.value.length === 0) {
loadStorePlugins()
}
}
onMounted(loadPlugins) onMounted(loadPlugins)
</script> </script>
@@ -75,8 +98,8 @@ onMounted(loadPlugins)
<div class="plugins-view"> <div class="plugins-view">
<n-space justify="space-between" align="center" style="margin-bottom: 24px;"> <n-space justify="space-between" align="center" style="margin-bottom: 24px;">
<div> <div>
<h2 style="margin: 0 0 8px 0;">插件管理</h2> <h2 style="margin: 0 0 8px 0;">扩展商店</h2>
<p style="margin: 0; color: #666;">查看和管理已注册的插件</p> <p style="margin: 0; color: #666;">管理已安装扩展和浏览扩展商店</p>
</div> </div>
<n-button quaternary @click="router.push('/')"> <n-button quaternary @click="router.push('/')">
<template #icon><n-icon><ArrowBackOutline /></n-icon></template> <template #icon><n-icon><ArrowBackOutline /></n-icon></template>
@@ -84,6 +107,9 @@ onMounted(loadPlugins)
</n-button> </n-button>
</n-space> </n-space>
<n-tabs v-model:value="activeTab" type="line" @update:value="handleTabChange">
<!-- 已安装扩展 -->
<n-tab-pane name="installed" tab="已安装">
<n-spin :show="loading"> <n-spin :show="loading">
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;"> <n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
<n-gi> <n-gi>
@@ -103,14 +129,15 @@ onMounted(loadPlugins)
</n-gi> </n-gi>
</n-grid> </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-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-gi v-for="plugin in plugins" :key="plugin.name">
<n-card hoverable> <n-card hoverable>
<template #header> <template #header>
<n-space align="center"> <n-space align="center">
<n-icon size="24" color="#18a058"><ExtensionPuzzleOutline /></n-icon> <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> <span>{{ plugin.name }}</span>
</n-space> </n-space>
</template> </template>
@@ -133,5 +160,39 @@ onMounted(loadPlugins)
</n-gi> </n-gi>
</n-grid> </n-grid>
</n-spin> </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> </div>
</template> </template>