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 {
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 服务端设置

View File

@@ -2,11 +2,19 @@ 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 插件数据
@@ -17,6 +25,7 @@ type PluginData struct {
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"`

View File

@@ -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
)
`)
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
}

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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"`

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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="暂无可用插件" />

View File

@@ -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,6 +107,9 @@ onMounted(loadPlugins)
</n-button>
</n-space>
<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>
@@ -103,14 +129,15 @@ onMounted(loadPlugins)
</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>
<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>
@@ -133,5 +160,39 @@ onMounted(loadPlugins)
</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>