feat(api): 添加从商店安装插件功能
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 30s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 50s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 49s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 57s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m7s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 45s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 58s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 51s

- 添加 /store/install API 端点用于从商店安装插件
- 实现 StorePluginInstallRequest 请求结构体
- 添加 handleStoreInstall 处理函数实现插件下载和安装逻辑
- 在客户端视图中添加商店插件安装模态框和相关功能
- 在插件视图中添加商店插件安装按钮和安装到客户端功能
- 添加 installStorePlugin API 函数用于前端调用
- 实现客户端在线状态检查和插件安装流程
This commit is contained in:
Flik
2025-12-29 19:40:34 +08:00
parent ab81e08100
commit d4984c8d78
4 changed files with 241 additions and 6 deletions

View File

@@ -131,6 +131,7 @@ func RegisterRoutes(r *Router, app AppInterface) {
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) api.HandleFunc("/store/plugins", h.handleStorePlugins)
api.HandleFunc("/store/install", h.handleStoreInstall)
api.HandleFunc("/client-plugin/", h.handleClientPlugin) api.HandleFunc("/client-plugin/", h.handleClientPlugin)
api.HandleFunc("/js-plugin/", h.handleJSPlugin) api.HandleFunc("/js-plugin/", h.handleJSPlugin)
api.HandleFunc("/js-plugins", h.handleJSPlugins) api.HandleFunc("/js-plugins", h.handleJSPlugins)
@@ -582,6 +583,13 @@ type StorePluginInfo struct {
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
} }
// StorePluginInstallRequest 从商店安装插件的请求
type StorePluginInstallRequest struct {
PluginName string `json:"plugin_name"`
DownloadURL string `json:"download_url"`
ClientID string `json:"client_id"`
}
// handleStorePlugins 处理扩展商店插件列表 // handleStorePlugins 处理扩展商店插件列表
func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) { func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@@ -625,6 +633,71 @@ func (h *APIHandler) handleStorePlugins(rw http.ResponseWriter, r *http.Request)
}) })
} }
// handleStoreInstall 从商店安装插件到客户端
func (h *APIHandler) handleStoreInstall(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req StorePluginInstallRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if req.PluginName == "" || req.DownloadURL == "" || req.ClientID == "" {
http.Error(rw, "plugin_name, download_url and client_id required", http.StatusBadRequest)
return
}
// 检查客户端是否在线
online, _, _ := h.server.GetClientStatus(req.ClientID)
if !online {
http.Error(rw, "client not online", http.StatusBadRequest)
return
}
// 下载插件
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(req.DownloadURL)
if err != nil {
http.Error(rw, "Failed to download plugin: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(rw, "Plugin download failed with status: "+resp.Status, http.StatusBadGateway)
return
}
source, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(rw, "Failed to read plugin: "+err.Error(), http.StatusInternalServerError)
return
}
// 安装到客户端
installReq := JSPluginInstallRequest{
PluginName: req.PluginName,
Source: string(source),
RuleName: req.PluginName,
AutoStart: true,
}
if err := h.server.InstallJSPluginToClient(req.ClientID, installReq); err != nil {
http.Error(rw, "Failed to install plugin: "+err.Error(), http.StatusInternalServerError)
return
}
h.jsonResponse(rw, map[string]interface{}{
"status": "ok",
"plugin": req.PluginName,
"client": req.ClientID,
})
}
// handleClientPlugin 处理客户端插件配置 // handleClientPlugin 处理客户端插件配置
// 路由: /api/client-plugin/{clientID}/{pluginName}/config // 路由: /api/client-plugin/{clientID}/{pluginName}/config
func (h *APIHandler) handleClientPlugin(rw http.ResponseWriter, r *http.Request) { func (h *APIHandler) handleClientPlugin(rw http.ResponseWriter, r *http.Request) {

View File

@@ -33,6 +33,8 @@ export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
// 扩展商店 // 扩展商店
export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins') export const getStorePlugins = () => get<{ plugins: StorePluginInfo[], store_url: string }>('/store/plugins')
export const installStorePlugin = (pluginName: string, downloadUrl: string, clientId: string) =>
post('/store/install', { plugin_name: pluginName, download_url: downloadUrl, client_id: clientId })
// 客户端插件配置 // 客户端插件配置
export const getClientPluginConfig = (clientId: string, pluginName: string) => export const getClientPluginConfig = (clientId: string, pluginName: string) =>

View File

@@ -4,18 +4,19 @@ 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, NSwitch, NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox, NSwitch,
NIcon, useMessage, useDialog NIcon, useMessage, useDialog, NSpin
} from 'naive-ui' } from 'naive-ui'
import { import {
ArrowBackOutline, CreateOutline, TrashOutline, ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline, PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
DownloadOutline, SettingsOutline DownloadOutline, SettingsOutline, StorefrontOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import { import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient,
getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig getPlugins, installPluginsToClient, getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin
} from '../api' } from '../api'
import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField, RuleSchema } from '../types' import type { ProxyRule, PluginInfo, ClientPlugin, ConfigField, RuleSchema, StorePluginInfo } from '../types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -77,6 +78,13 @@ const configSchema = ref<ConfigField[]>([])
const configValues = ref<Record<string, string>>({}) const configValues = ref<Record<string, string>>({})
const configLoading = ref(false) const configLoading = ref(false)
// 商店插件安装相关
const showStoreModal = ref(false)
const storePlugins = ref<StorePluginInfo[]>([])
const storeLoading = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
const storeInstalling = ref(false)
const loadPlugins = async () => { const loadPlugins = async () => {
try { try {
const { data } = await getPlugins() const { data } = await getPlugins()
@@ -110,6 +118,42 @@ const openInstallModal = async () => {
showInstallModal.value = true showInstallModal.value = true
} }
// 商店插件相关函数
const openStoreModal = async () => {
showStoreModal.value = true
storeLoading.value = true
selectedStorePlugin.value = null
try {
const { data } = await getStorePlugins()
storePlugins.value = (data.plugins || []).filter(p => p.download_url)
} catch (e) {
console.error('Failed to load store plugins', e)
message.error('加载商店插件失败')
} finally {
storeLoading.value = false
}
}
const handleInstallStorePlugin = async (plugin: StorePluginInfo) => {
if (!plugin.download_url) {
message.error('该插件没有下载地址')
return
}
storeInstalling.value = true
selectedStorePlugin.value = plugin
try {
await installStorePlugin(plugin.name, plugin.download_url, clientId)
message.success(`已安装 ${plugin.name}`)
showStoreModal.value = false
await loadClient()
} catch (e: any) {
message.error(e.response?.data || '安装失败')
} finally {
storeInstalling.value = false
selectedStorePlugin.value = null
}
}
const getTypeLabel = (type: string) => { const getTypeLabel = (type: string) => {
const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' } const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
return labels[type] || type return labels[type] || type
@@ -350,6 +394,10 @@ const savePluginConfig = async () => {
<template #icon><n-icon><DownloadOutline /></n-icon></template> <template #icon><n-icon><DownloadOutline /></n-icon></template>
安装插件 安装插件
</n-button> </n-button>
<n-button @click="openStoreModal">
<template #icon><n-icon><StorefrontOutline /></n-icon></template>
从商店安装
</n-button>
<n-button type="warning" @click="disconnect"> <n-button type="warning" @click="disconnect">
<template #icon><n-icon><PowerOutline /></n-icon></template> <template #icon><n-icon><PowerOutline /></n-icon></template>
断开连接 断开连接
@@ -598,5 +646,39 @@ const savePluginConfig = async () => {
</n-space> </n-space>
</template> </template>
</n-modal> </n-modal>
<!-- 商店插件安装模态框 -->
<n-modal v-model:show="showStoreModal" preset="card" title="从商店安装插件" style="width: 500px;">
<n-spin :show="storeLoading">
<n-empty v-if="!storeLoading && storePlugins.length === 0" description="商店暂无可用插件" />
<n-space v-else vertical :size="12">
<n-card v-for="plugin in storePlugins" :key="plugin.name" size="small">
<n-space justify="space-between" align="center">
<n-space vertical :size="4">
<n-space align="center">
<span style="font-weight: 500;">{{ plugin.name }}</span>
<n-tag size="small">v{{ plugin.version }}</n-tag>
</n-space>
<span style="color: #666; font-size: 12px;">{{ plugin.description }}</span>
<span style="color: #999; font-size: 12px;">作者: {{ plugin.author }}</span>
</n-space>
<n-button
size="small"
type="primary"
:loading="storeInstalling && selectedStorePlugin?.name === plugin.name"
@click="handleInstallStorePlugin(plugin)"
>
安装
</n-button>
</n-space>
</n-card>
</n-space>
</n-spin>
<template #footer>
<n-space justify="end">
<n-button @click="showStoreModal = false">关闭</n-button>
</n-space>
</template>
</n-modal>
</div> </div>
</template> </template>

View File

@@ -4,10 +4,13 @@ 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, NTabs, NTabPane, useMessage, NEmpty, NSpin, NIcon, NSwitch, NTabs, NTabPane, useMessage,
NSelect NSelect, NModal
} from 'naive-ui' } from 'naive-ui'
import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline } from '@vicons/ionicons5' import { ArrowBackOutline, ExtensionPuzzleOutline, StorefrontOutline, CodeSlashOutline } from '@vicons/ionicons5'
import { getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins, pushJSPluginToClient, getClients } from '../api' import {
getPlugins, enablePlugin, disablePlugin, getStorePlugins, getJSPlugins,
pushJSPluginToClient, getClients, installStorePlugin
} from '../api'
import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types' import type { PluginInfo, StorePluginInfo, JSPlugin, ClientStatus } from '../types'
const router = useRouter() const router = useRouter()
@@ -141,6 +144,43 @@ const handlePushJSPlugin = async (pluginName: string, clientId: string) => {
const onlineClients = computed(() => clients.value.filter(c => c.online)) const onlineClients = computed(() => clients.value.filter(c => c.online))
// 商店插件安装相关
const showInstallModal = ref(false)
const selectedStorePlugin = ref<StorePluginInfo | null>(null)
const selectedClientId = ref('')
const installing = ref(false)
const openInstallModal = (plugin: StorePluginInfo) => {
selectedStorePlugin.value = plugin
selectedClientId.value = ''
showInstallModal.value = true
}
const handleInstallStorePlugin = async () => {
if (!selectedStorePlugin.value || !selectedClientId.value) {
message.warning('请选择要安装到的客户端')
return
}
if (!selectedStorePlugin.value.download_url) {
message.error('该插件没有下载地址')
return
}
installing.value = true
try {
await installStorePlugin(
selectedStorePlugin.value.name,
selectedStorePlugin.value.download_url,
selectedClientId.value
)
message.success(`已安装 ${selectedStorePlugin.value.name} 到客户端`)
showInstallModal.value = false
} catch (e: any) {
message.error(e.response?.data || '安装失败')
} finally {
installing.value = false
}
}
onMounted(() => { onMounted(() => {
loadPlugins() loadPlugins()
loadClients() loadClients()
@@ -231,6 +271,16 @@ onMounted(() => {
<span>{{ plugin.name }}</span> <span>{{ plugin.name }}</span>
</n-space> </n-space>
</template> </template>
<template #header-extra>
<n-button
v-if="plugin.download_url && onlineClients.length > 0"
size="small"
type="primary"
@click="openInstallModal(plugin)"
>
安装
</n-button>
</template>
<n-space vertical :size="8"> <n-space vertical :size="8">
<n-space> <n-space>
<n-tag size="small">v{{ plugin.version }}</n-tag> <n-tag size="small">v{{ plugin.version }}</n-tag>
@@ -310,5 +360,33 @@ onMounted(() => {
... 已屏蔽 ... ... 已屏蔽 ...
</n-modal> </n-modal>
--> -->
<!-- 安装商店插件模态框 -->
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件" style="width: 400px;">
<n-space vertical :size="16">
<div v-if="selectedStorePlugin">
<p style="margin: 0 0 8px 0;"><strong>插件:</strong> {{ selectedStorePlugin.name }}</p>
<p style="margin: 0; color: #666;">{{ selectedStorePlugin.description }}</p>
</div>
<n-select
v-model:value="selectedClientId"
placeholder="选择要安装到的客户端"
:options="onlineClients.map(c => ({ label: c.nickname || c.id, value: c.id }))"
/>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showInstallModal = false">取消</n-button>
<n-button
type="primary"
:loading="installing"
:disabled="!selectedClientId"
@click="handleInstallStorePlugin"
>
安装
</n-button>
</n-space>
</template>
</n-modal>
</div> </div>
</template> </template>