新增简易的8080面板😊

This commit is contained in:
Falck
2026-04-17 23:15:15 +08:00
parent c38d2f66d1
commit 9d19d09821
465 changed files with 9235 additions and 35285 deletions

View File

@@ -0,0 +1,8 @@
{
"signature": "hNzQ56uwgghPRTVm5YFA8fZp+1Y9TQ9fSDKLEY+KPFLddrxdnXZiE66XXWEVEj80pB5E/zJ0nDcpJYTe9+Mo4LQ++Qzt7yA+PMu8WZ/I39f1870FR/s+MuaiKWp0sT/NeyHRv/nHKi/FaZXWx+KsSbKatq4w088bNhyWahJg1RmTaCKAxv7ut9Uqn33m9teoeNt43AG/6ySfRQRfk0K1L7Yvf/9yJStDMAuTzFiQmhs4MZ58VzPh/Nrtj0G7N5mAjp9bZKa+EFqMLFBQlG5TDqWU8zFKBe27CsvSK7MthS3PGyzeGftm2O683hgClGdsgdK9kqwZ0eMOb5Jcesk4f0rWVODpCf2cfRPocrs401yKzVU3dStFw14Bq82SpQDRJ9EDU3lP8E4RqlmXEAzlGNoMsGSGth9gSWc4VpHn4ppVH5ftKk/AvJrpdFWyWe0jPnDODRKAIMn9sGiZUy6XqB0fGMoU0vpuvtLy6mtVmQglhsVE49XA5txAEWQncPUPxxjNoMdRo5RDlimRVNtXNcwKRb1z9V6ky1eOVKFHaPsp4Y+1mreZVUokaUBf8LG1qvFXjZuiYHRlffKSN3/yzRqhDnE5fCDu0wpjHe24dZ/PeQXbG2aAQlJQr15yh7p5dxTSiv+HeacwDqZPF8X/9Ey6xMflr1xGZpp9j9YeCtk=",
"signer": "FutureOSS",
"algorithm": "RSA-SHA256",
"timestamp": 1775967812.6803007,
"plugin_hash": "c0c56583082ca71e9a84ac2e976c22683573ec4e40387ee893ac42f31da62d4a",
"author": "FutureOSS"
}

View File

@@ -0,0 +1,485 @@
"""包管理插件 - 提供插件配置管理和商店界面"""
import os
import sys
import json
import urllib.request
from pathlib import Path
from oss.logger.logger import Log
from oss.plugin.types import Plugin, Response, register_plugin_type
# Gitee 仓库配置
GITEE_OWNER = "starlight-apk"
GITEE_REPO = "future-oss"
GITEE_BRANCH = "main"
# 使用 raw 文件 URL不走 API无频率限制
GITEE_RAW_BASE = f"https://gitee.com/{GITEE_OWNER}/{GITEE_REPO}/raw/{GITEE_BRANCH}"
GITEE_API_BASE = f"https://gitee.com/api/v5/repos/{GITEE_OWNER}/{GITEE_REPO}/contents"
# Gitee Token从环境变量读取可选
GITEE_TOKEN = os.environ.get("GITEE_TOKEN", "")
def _gitee_request(url: str, timeout: int = 15):
"""Gitee 请求"""
req = urllib.request.Request(url)
req.add_header("User-Agent", "FutureOSS-PkgManager")
if GITEE_TOKEN:
# Gitee 使用私人令牌认证
req.add_header("Authorization", f"token {GITEE_TOKEN}")
return urllib.request.urlopen(req, timeout=timeout)
class PkgManagerPlugin(Plugin):
"""包管理插件"""
def __init__(self):
self.webui = None
self.storage = None
self.store_dir = Path("./store")
self._remote_cache = None
self._cache_time = 0
self._cache_ttl = 300 # 5分钟缓存
def meta(self):
from oss.plugin.types import Metadata, PluginConfig, Manifest
return Manifest(
metadata=Metadata(
name="pkg-manager",
version="1.0.0",
author="FutureOSS",
description="插件包管理器 - 配置管理和商店"
),
config=PluginConfig(enabled=True, args={}),
dependencies=["http-api", "webui", "plugin-storage"]
)
def set_webui(self, webui):
self.webui = webui
def set_plugin_storage(self, storage):
self.storage = storage
def init(self, deps: dict = None):
"""init 阶段:注册页面到 WebUI"""
if not self.webui:
Log.warn("pkg-manager", "警告: 未找到 WebUI 依赖")
return
self.webui.register_page(
path='/packages',
content_provider=self._packages_content,
nav_item={'icon': 'ri-apps-line', 'text': '插件管理'}
)
self.webui.register_page(
path='/store',
content_provider=self._store_content,
nav_item={'icon': 'ri-store-2-line', 'text': '插件商店'}
)
Log.info("pkg-manager", "已注册到 WebUI 导航")
def start(self):
"""启动阶段:注册 API 路由"""
if not self.webui or not hasattr(self.webui, 'server') or not self.webui.server:
Log.warn("pkg-manager", "警告: WebUI 服务器未就绪")
return
router = self.webui.server.router
# API - 已安装插件
router.get("/api/plugins", self._handle_list_plugins)
router.get("/api/plugins/:name/config", self._handle_get_config)
router.post("/api/plugins/:name/config", self._handle_save_config)
router.get("/api/plugins/:name/info", self._handle_get_plugin_info)
router.post("/api/plugins/:name/uninstall", self._handle_uninstall)
# API - 远程商店
router.get("/api/store/remote", self._handle_remote_store)
router.post("/api/store/install", self._handle_store_install)
Log.info("pkg-manager", "包管理器已启动")
def stop(self):
Log.error("pkg-manager", "包管理器已停止")
# ==================== 页面渲染 ====================
def _packages_content(self) -> str:
return self._render_php_view('packages.php', {'pageTitle': '插件管理'})
def _store_content(self) -> str:
return self._render_php_view('store.php', {'pageTitle': '插件商店'})
def _render_php_view(self, view_name: str, variables: dict) -> str:
import subprocess
views_dir = os.path.join(os.path.dirname(__file__), 'views')
php_file = os.path.join(views_dir, view_name)
if not os.path.exists(php_file):
return f"<h1>错误: 找不到 {view_name}</h1>"
php_vars = ""
for key, value in variables.items():
if isinstance(value, str):
php_vars += f"${key} = '{value}';\n"
else:
php_vars += f"${key} = {json.dumps(value)};\n"
with open(php_file, 'r', encoding='utf-8') as f:
php_content = f.read()
tmp_file = os.path.join(views_dir, '.temp_pkg.php')
try:
with open(tmp_file, 'w', encoding='utf-8') as f:
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
result = subprocess.run(
["php", "-f", tmp_file],
capture_output=True, text=True, timeout=10, cwd=views_dir
)
return result.stdout if result.returncode == 0 else f"<pre>{result.stderr}</pre>"
finally:
try:
if os.path.exists(tmp_file):
os.unlink(tmp_file)
except:
pass
# ==================== API 处理 ====================
def _handle_list_plugins(self, request):
"""列出所有已安装插件"""
plugins = self._scan_all_plugins()
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(plugins, ensure_ascii=False))
def _handle_get_config(self, request):
"""获取插件配置 schema + 当前值"""
plugin_name = request.path_params.get('name', '')
schema = self._load_config_schema(plugin_name)
current = self._load_plugin_config(plugin_name)
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps({
"schema": schema,
"current": current
}, ensure_ascii=False))
def _handle_save_config(self, request):
"""保存插件配置"""
import json as json_mod
try:
body = json_mod.loads(request.body)
plugin_name = request.path_params.get('name', '')
self._save_plugin_config(plugin_name, body)
return Response(status=200, headers={"Content-Type": "application/json"}, body='{"ok":true}')
except Exception as e:
return Response(status=500, headers={"Content-Type": "application/json"}, body=json.dumps({"error": str(e)}))
def _handle_get_plugin_info(self, request):
"""获取插件详细信息"""
plugin_name = request.path_params.get('name', '')
info = self._get_plugin_detailed_info(plugin_name)
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(info, ensure_ascii=False))
def _handle_uninstall(self, request):
"""卸载插件"""
import shutil
plugin_name = request.path_params.get('name', '')
# 查找插件目录
plugin_dir = self._find_plugin_dir(plugin_name)
if not plugin_dir:
return Response(status=404, body='{"error":"插件未安装"}')
try:
shutil.rmtree(plugin_dir)
return Response(status=200, body='{"ok":true}')
except Exception as e:
return Response(status=500, body=json.dumps({"error": str(e)}))
def _handle_remote_store(self, request):
"""从 Gitee API 获取远程插件列表"""
try:
plugins = self._fetch_remote_plugins()
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps(plugins, ensure_ascii=False))
except Exception as e:
return Response(status=500, body=json.dumps({"error": str(e)}))
def _handle_store_install(self, request):
"""安装插件"""
import json as json_mod
try:
body = json_mod.loads(request.body)
name = body.get("name", "")
author = body.get("author", "FutureOSS")
success = self._install_from_gitee(name, author)
return Response(status=200, body=json.dumps({"ok": success}))
except Exception as e:
return Response(status=500, body=json.dumps({"error": str(e)}))
# ==================== Gitee 远程商店 ====================
def _fetch_remote_plugins(self) -> list:
"""从 Gitee 获取所有可用插件(带缓存+限速+重试)"""
import time
now = time.time()
if self._remote_cache and (now - self._cache_time) < self._cache_ttl:
return self._remote_cache
plugins = []
try:
store_url = f"{GITEE_API_BASE}/store"
# 重试 3 次,每次间隔增加
for attempt in range(3):
try:
with _gitee_request(store_url, timeout=15) as resp:
dirs = json.loads(resp.read().decode("utf-8"))
break
except Exception as e:
if attempt < 2:
time.sleep(1 + attempt)
continue
raise
time.sleep(0.5)
for dir_info in dirs:
if dir_info.get("type") != "dir":
continue
author = dir_info.get("name", "")
if not author.startswith("@{"):
continue
author_url = f"{GITEE_API_BASE}/store/{urllib.parse.quote(author, safe='')}"
for attempt in range(3):
try:
with _gitee_request(author_url, timeout=15) as resp:
plugin_dirs = json.loads(resp.read().decode("utf-8"))
break
except Exception:
if attempt < 2:
time.sleep(1 + attempt)
continue
raise
time.sleep(0.5)
for plugin_dir in plugin_dirs:
if plugin_dir.get("type") != "dir":
continue
plugin_name = plugin_dir.get("name", "")
manifest_url = f"{GITEE_API_BASE}/store/{urllib.parse.quote(author, safe='')}/{plugin_name}/manifest.json"
manifest = {}
for attempt in range(3):
try:
with _gitee_request(manifest_url, timeout=15) as resp:
manifest = json.loads(resp.read().decode("utf-8"))
break
except Exception:
if attempt < 2:
time.sleep(1 + attempt)
continue
plugins.append({
"name": plugin_name,
"author": author,
"full_name": f"{author}/{plugin_name}",
"metadata": manifest.get("metadata", {}),
"dependencies": manifest.get("dependencies", []),
"has_config": False,
"is_installed": self._is_plugin_installed(plugin_name, author)
})
time.sleep(0.5)
self._remote_cache = plugins
self._cache_time = now
except Exception as e:
Log.error("pkg-manager", f"获取远程插件列表失败: {e}")
return plugins
def _install_from_gitee(self, plugin_name: str, author: str) -> bool:
"""从 Gitee 下载并安装插件(使用 raw URL"""
import shutil, time
install_dir = self.store_dir / author / plugin_name
install_dir.mkdir(parents=True, exist_ok=True)
try:
# 获取目录结构(需要一次 API 调用)
api_url = f"{GITEE_API_BASE}/store/{author}/{plugin_name}"
with _gitee_request(api_url, timeout=15) as resp:
items = json.loads(resp.read().decode("utf-8"))
time.sleep(0.5)
for item in items:
if item.get("type") == "file":
# 使用 raw URL 下载文件(不走 API
filename = item.get("name")
raw_url = f"{GITEE_RAW_BASE}/store/{author}/{plugin_name}/{filename}"
local_file = install_dir / filename
try:
with _gitee_request(raw_url, timeout=15) as resp:
content = resp.read()
with open(local_file, 'wb') as f:
f.write(content)
except:
pass
elif item.get("type") == "dir":
sub_dir = item.get("name")
self._download_dir_raw(author, plugin_name, sub_dir, install_dir / sub_dir)
time.sleep(0.3)
Log.info("pkg-manager", f"已安装: {author}/{plugin_name}")
return True
except Exception as e:
Log.error("pkg-manager", f"安装失败 {plugin_name}: {e}")
if install_dir.exists():
shutil.rmtree(install_dir)
return False
def _download_dir_raw(self, author: str, plugin: str, sub_dir: str, local_dir: Path):
"""使用 raw URL 递归下载子目录"""
import time
try:
api_url = f"{GITEE_API_BASE}/store/{author}/{plugin}/{sub_dir}"
with _gitee_request(api_url, timeout=15) as resp:
items = json.loads(resp.read().decode("utf-8"))
local_dir.mkdir(parents=True, exist_ok=True)
for item in items:
if item.get("type") == "file":
filename = item.get("name")
raw_url = f"{GITEE_RAW_BASE}/store/{author}/{plugin}/{sub_dir}/{filename}"
try:
with _gitee_request(raw_url, timeout=15) as resp:
content = resp.read()
with open(local_dir / filename, 'wb') as f:
f.write(content)
except:
pass
elif item.get("type") == "dir":
self._download_dir_raw(author, plugin, f"{sub_dir}/{item.get('name')}", local_dir / item.get("name"))
except:
pass
# ==================== 辅助方法 ====================
def _scan_all_plugins(self) -> list:
"""扫描本地已安装插件"""
plugins = []
if not self.store_dir.exists():
return plugins
for author_dir in self.store_dir.iterdir():
if author_dir.is_dir() and author_dir.name.startswith("@{"):
for plugin_dir in author_dir.iterdir():
if plugin_dir.is_dir() and (plugin_dir / "main.py").exists():
manifest_path = plugin_dir / "manifest.json"
if manifest_path.exists():
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
plugins.append({
"name": plugin_dir.name,
"full_name": f"{author_dir.name}/{plugin_dir.name}",
"author": author_dir.name,
"metadata": manifest.get("metadata", {}),
"dependencies": manifest.get("dependencies", []),
"has_config": (plugin_dir / "config.json").exists(),
"is_installed": True
})
return plugins
def _is_plugin_installed(self, plugin_name: str, author: str) -> bool:
"""检查插件是否已安装"""
plugin_dir = self.store_dir / author / plugin_name
return (plugin_dir / "main.py").exists()
def _find_plugin_dir(self, plugin_name: str) -> Path | None:
"""查找插件目录"""
if not self.store_dir.exists():
return None
for author_dir in self.store_dir.iterdir():
if author_dir.is_dir():
plugin_dir = author_dir / plugin_name
if plugin_dir.exists() and (plugin_dir / "main.py").exists():
return plugin_dir
return None
def _load_config_schema(self, plugin_name: str) -> dict:
"""加载插件 config.json schema"""
plugin_dir = self._find_plugin_dir(plugin_name)
if not plugin_dir:
return {}
schema_path = plugin_dir / "config.json"
if not schema_path.exists():
return {}
with open(schema_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _load_plugin_config(self, plugin_name: str) -> dict:
"""加载插件当前配置"""
schema = self._load_config_schema(plugin_name)
defaults = {}
for key, field_def in schema.items():
defaults[key] = field_def.get("default")
if self.storage:
storage_instance = self.storage.get_storage("pkg-manager")
user_config = storage_instance.get(f"plugin_config.{plugin_name}", {})
defaults.update(user_config)
return defaults
def _save_plugin_config(self, plugin_name: str, config: dict):
"""保存插件配置"""
if self.storage:
storage_instance = self.storage.get_storage("pkg-manager")
storage_instance.set(f"plugin_config.{plugin_name}", config)
def _get_plugin_detailed_info(self, plugin_name: str) -> dict:
"""获取插件的依赖、事件、页面信息"""
dependencies = []
events = [] # 事件 = 功能描述
plugin_dir = self._find_plugin_dir(plugin_name)
if plugin_dir:
manifest_path = plugin_dir / "manifest.json"
if manifest_path.exists():
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
dependencies = manifest.get("dependencies", [])
# 从 manifest 的 metadata.description 或 type 中提取功能
metadata = manifest.get("metadata", {})
plugin_type = metadata.get("type", "")
if plugin_type:
events.append(f"类型: {plugin_type}")
# 从 manifest config 推断功能
config = manifest.get("config", {})
if config.get("enabled"):
events.append("已启用")
# 只返回该插件自己注册的页面(通过插件名匹配)
pages = []
if self.webui and hasattr(self.webui, 'server') and self.webui.server:
for path, provider in self.webui.server.pages.items():
# 检查 provider 是否属于该插件
provider_name = getattr(provider, '__self__', None)
if provider_name and isinstance(provider_name, PkgManagerPlugin):
continue # 跳过自己的页面
# 通过路径前缀判断dashboard 注册 /dashboard
if path == f'/{plugin_name}' or path.startswith(f'/{plugin_name}/'):
pages.append({"path": path})
# 特殊处理:首页
if plugin_name == 'webui' and path == '/':
pages.append({"path": path})
return {
"name": plugin_name,
"dependencies": dependencies,
"config_fields": list(self._load_config_schema(plugin_name).keys()),
"pages": pages,
"events": events
}
register_plugin_type("PkgManagerPlugin", PkgManagerPlugin)
def New():
return PkgManagerPlugin()

View File

@@ -0,0 +1,15 @@
{
"metadata": {
"name": "pkg-manager",
"version": "1.0.0",
"author": "FutureOSS",
"description": "插件包管理器 - 配置管理和商店",
"type": "webui-extension"
},
"config": {
"enabled": true,
"args": {}
},
"dependencies": ["http-api", "webui", "plugin-storage"],
"permissions": ["*"]
}

View File

@@ -0,0 +1,337 @@
<div class="packages-page" x-data="packagesApp()" x-init="init()">
<style>
.packages-page { display: flex; height: calc(100vh - 40px); }
.pkg-sidebar {
width: 300px; min-width: 300px; background: #fff; border-right: 1px solid #e8ecf0;
display: flex; flex-direction: column;
}
.pkg-sidebar-header { padding: 20px; border-bottom: 1px solid #f0f0f0; }
.pkg-sidebar-header h3 { font-size: 16px; font-weight: 600; color: #1a1a2e; }
.pkg-search { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; }
.pkg-search input {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
border-radius: 8px; font-size: 13px; outline: none; box-sizing: border-box;
}
.pkg-search input:focus { border-color: #4a90d9; }
.pkg-list { flex: 1; overflow-y: auto; }
.pkg-item {
padding: 14px 16px; cursor: pointer; border-bottom: 1px solid #f8f8f8;
transition: background 0.15s;
}
.pkg-item:hover { background: #f8f9fa; }
.pkg-item.active { background: #eef4fb; border-left: 3px solid #4a90d9; }
.pkg-item-name { font-size: 14px; font-weight: 500; color: #333; }
.pkg-item-desc { font-size: 12px; color: #999; margin-top: 4px; }
.pkg-item-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 6px; align-items: center; }
.pkg-item-status { color: #2ecc71; }
.pkg-content { flex: 1; overflow-y: auto; padding: 24px 32px; background: #f9fafb; }
.pkg-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: #999; font-size: 15px; }
.pkg-config-header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-start; }
.pkg-config-header h2 { font-size: 22px; font-weight: 600; color: #1a1a2e; }
.pkg-config-header p { color: #888; font-size: 14px; margin-top: 4px; }
.pkg-info-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.pkg-info-tag {
display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
background: #fff; border-radius: 8px; font-size: 13px; color: #555; border: 1px solid #e8ecf0;
}
.pkg-info-tag i { font-size: 16px; }
.pkg-info-tag .count {
background: #4a90d9; color: #fff; border-radius: 10px; padding: 1px 7px; font-size: 11px;
}
.config-section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.config-section h4 { font-size: 15px; font-weight: 600; color: #1a1a2e; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
.config-field { margin-bottom: 20px; }
.config-field label { display: block; font-size: 13px; font-weight: 500; color: #333; margin-bottom: 6px; }
.config-field .desc { font-size: 12px; color: #999; margin-bottom: 8px; }
.config-field input[type="text"],
.config-field input[type="number"],
.config-field textarea,
.config-field select {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
border-radius: 8px; font-size: 13px; outline: none; transition: border-color 0.2s;
box-sizing: border-box;
}
.config-field input:focus, .config-field select:focus, .config-field textarea:focus { border-color: #4a90d9; }
.config-field textarea { min-height: 80px; resize: vertical; }
.toggle { position: relative; display: inline-flex; align-items: center; gap: 10px; cursor: pointer; }
.toggle input { display: none; }
.toggle-slider { width: 44px; height: 24px; background: #ddd; border-radius: 12px; position: relative; transition: background 0.2s; }
.toggle-slider::after {
content: ''; position: absolute; width: 20px; height: 20px; background: #fff;
border-radius: 50%; top: 2px; left: 2px; transition: transform 0.2s;
}
.toggle input:checked + .toggle-slider { background: #4a90d9; }
.toggle input:checked + .toggle-slider::after { transform: translateX(20px); }
.radio-group, .checkbox-group { display: flex; flex-wrap: wrap; gap: 8px; }
.radio-option, .checkbox-option {
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
border: 1px solid #e0e0e0; border-radius: 8px; cursor: pointer;
font-size: 13px; transition: all 0.15s;
}
.radio-option:hover, .checkbox-option:hover { border-color: #4a90d9; background: #f0f5fc; }
.radio-option.selected, .checkbox-option.selected { border-color: #4a90d9; background: #eef4fb; color: #4a90d9; }
.action-btns { display: flex; gap: 12px; margin-top: 8px; }
.save-btn { padding: 10px 24px; background: #4a90d9; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; }
.save-btn:hover { background: #3a7bc8; }
.save-btn:disabled { background: #ccc; cursor: not-allowed; }
.uninstall-btn { padding: 10px 24px; background: #fff; color: #e74c3c; border: 1px solid #e74c3c; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.uninstall-btn:hover { background: #fee; }
.status-msg { padding: 8px 12px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
.status-msg.success { background: #e8f8ef; color: #2ecc71; }
.status-msg.error { background: #fde8e8; color: #e74c3c; }
</style>
<!-- 左栏:已安装插件列表 -->
<div class="pkg-sidebar">
<div class="pkg-sidebar-header"><h3>已安装插件</h3></div>
<div class="pkg-search">
<input type="text" placeholder="搜索插件..." x-model="searchQuery" />
</div>
<div class="pkg-list">
<template x-for="plugin in filteredPlugins" :key="plugin.name">
<div class="pkg-item" :class="{ active: selectedPlugin?.name === plugin.name }"
@click="selectPlugin(plugin)">
<div class="pkg-item-name" x-text="plugin.metadata.name || plugin.name"></div>
<div class="pkg-item-desc" x-text="plugin.metadata.description || '暂无描述'"></div>
<div class="pkg-item-meta">
<span x-text="'v' + (plugin.metadata.version || '?')"></span>
<span style="color:#888;" x-text="'by ' + plugin.author"></span>
<span x-show="plugin.has_config" style="color:#4a90d9;">⚙️</span>
</div>
</div>
</template>
</div>
</div>
<!-- 右栏:配置面板 -->
<div class="pkg-content">
<template x-if="!selectedPlugin">
<div class="pkg-empty"> 选择一个插件以查看配置</div>
</template>
<template x-if="selectedPlugin">
<div>
<div class="pkg-config-header">
<div>
<h2 x-text="selectedPlugin.metadata.name || selectedPlugin.name"></h2>
<p x-text="selectedPlugin.metadata.description"></p>
</div>
</div>
<!-- 信息栏:依赖、页面、事件(只在有数据时显示) -->
<div class="pkg-info-bar">
<div class="pkg-info-tag" x-show="pluginDeps.length > 0">
<i class="ri-plug-line"></i>
<span>依赖:</span>
<template x-for="dep in pluginDeps" :key="dep">
<span class="count" x-text="dep"></span>
</template>
</div>
<div class="pkg-info-tag" x-show="pluginPages.length > 0">
<i class="ri-pages-line"></i>
<span>页面:</span>
<template x-for="pg in pluginPages" :key="pg.path">
<span class="count" x-text="pg.path"></span>
</template>
</div>
<div class="pkg-info-tag" x-show="pluginEvents.length > 0">
<i class="ri-flashlight-line"></i>
<span>事件:</span>
<template x-for="evt in pluginEvents" :key="evt">
<span class="count" x-text="evt"></span>
</template>
</div>
</div>
<!-- 配置表单 -->
<div x-show="configSchema && Object.keys(configSchema).length > 0">
<div class="config-section">
<h4>⚙️ 配置</h4>
<template x-for="[key, field] in sortedConfigFields" :key="key">
<div class="config-field" x-show="isFieldVisible(key, field)">
<label x-text="field.name || key"></label>
<div class="desc" x-text="field.description"></div>
<template x-if="field.type === 'string'">
<input type="text" x-model="configValues[key]" />
</template>
<template x-if="field.type === 'number'">
<input type="number" x-model.number="configValues[key]" :min="field.min ?? 0" :max="field.max ?? 99999" />
</template>
<template x-if="field.type === 'boolean'">
<label class="toggle">
<input type="checkbox" x-model="configValues[key]" />
<span class="toggle-slider"></span>
<span x-text="configValues[key] ? '已开启' : '已关闭'"></span>
</label>
</template>
<template x-if="field.type === 'select'">
<div class="radio-group">
<template x-for="opt in field.options" :key="opt.value">
<div class="radio-option" :class="{ selected: configValues[key] === opt.value }"
@click="configValues[key] = opt.value">
<span x-text="opt.label"></span>
</div>
</template>
</div>
</template>
<template x-if="field.type === 'list'">
<div class="checkbox-group">
<template x-for="opt in field.options" :key="opt.value">
<div class="checkbox-option" :class="{ selected: (configValues[key] || []).includes(opt.value) }"
@click="toggleListValue(key, opt.value)">
<span x-text="opt.label"></span>
</div>
</template>
</div>
</template>
<template x-if="field.type === 'textarea'">
<textarea x-model="configValues[key]"></textarea>
</template>
</div>
</template>
</div>
<div class="action-btns">
<button class="save-btn" @click="saveConfig()" :disabled="saving">
<span x-show="!saving">💾 保存配置</span>
<span x-show="saving">保存中...</span>
</button>
<button class="uninstall-btn" @click="uninstallPlugin()">
🗑️ 卸载插件
</button>
</div>
<div class="status-msg" :class="saveStatus.type" x-show="saveStatus.msg" x-text="saveStatus.msg"></div>
</div>
<div x-show="!configSchema || Object.keys(configSchema).length === 0" class="config-section">
<p style="color:#999;">该插件没有可配置的选项</p>
<div class="action-btns">
<button class="uninstall-btn" @click="uninstallPlugin()">🗑️ 卸载插件</button>
</div>
</div>
</div>
</template>
</div>
<script>
function packagesApp() {
return {
plugins: [],
searchQuery: '',
selectedPlugin: null,
configSchema: {},
configValues: {},
pluginDeps: [],
pluginPages: [],
pluginEvents: [],
saving: false,
saveStatus: { type: '', msg: '' },
init() { this.loadPlugins(); },
async loadPlugins() {
const res = await fetch('/api/plugins');
this.plugins = await res.json();
},
get filteredPlugins() {
if (!this.searchQuery) return this.plugins;
const q = this.searchQuery.toLowerCase();
return this.plugins.filter(p =>
(p.metadata.name || '').toLowerCase().includes(q) ||
(p.metadata.description || '').toLowerCase().includes(q) ||
p.name.toLowerCase().includes(q)
);
},
get sortedConfigFields() {
if (!this.configSchema) return [];
return Object.entries(this.configSchema).sort((a, b) => (a[1].order || 99) - (b[1].order || 99));
},
async selectPlugin(plugin) {
this.selectedPlugin = plugin;
this.configSchema = {};
this.configValues = {};
this.pluginDeps = [];
this.pluginPages = [];
this.pluginEvents = [];
if (plugin.has_config) {
const res = await fetch(`/api/plugins/${plugin.name}/config`);
const data = await res.json();
this.configSchema = data.schema || {};
this.configValues = data.current || {};
}
const infoRes = await fetch(`/api/plugins/${plugin.name}/info`);
const info = await infoRes.json();
this.pluginDeps = info.dependencies || [];
this.pluginPages = info.pages || [];
this.pluginEvents = info.events || [];
},
isFieldVisible(key, field) {
if (field.show_when) {
return this.configValues[field.show_when.field] === field.show_when.value;
}
return true;
},
toggleListValue(key, value) {
if (!this.configValues[key]) this.configValues[key] = [];
const idx = this.configValues[key].indexOf(value);
if (idx >= 0) this.configValues[key].splice(idx, 1);
else this.configValues[key].push(value);
},
async saveConfig() {
this.saving = true;
this.saveStatus = {};
try {
const res = await fetch(`/api/plugins/${this.selectedPlugin.name}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.configValues)
});
if (res.ok) {
this.saveStatus = { type: 'success', msg: '✅ 配置已保存' };
} else {
this.saveStatus = { type: 'error', msg: '❌ 保存失败' };
}
} catch (e) {
this.saveStatus = { type: 'error', msg: '❌ 网络错误' };
}
this.saving = false;
setTimeout(() => { this.saveStatus.msg = ''; }, 3000);
},
async uninstallPlugin() {
if (!confirm('确定要卸载 ' + (this.selectedPlugin.metadata.name || this.selectedPlugin.name) + ' 吗?\n卸载后需要重启 FutureOSS 才能完全生效。')) return;
try {
const res = await fetch(`/api/plugins/${this.selectedPlugin.name}/uninstall`, { method: 'POST' });
const data = await res.json();
if (data.ok) {
alert('✅ 已卸载,请重启 FutureOSS');
this.loadPlugins();
this.selectedPlugin = null;
} else {
alert('❌ 卸载失败: ' + (data.error || '未知错误'));
}
} catch (e) { alert('❌ 网络错误'); }
}
};
}
</script>
</div>

View File

@@ -0,0 +1,197 @@
<div class="store-page" x-data="storeApp()" x-init="init()">
<style>
.store-page { display: flex; height: calc(100vh - 40px); }
.store-sidebar {
width: 220px; min-width: 220px; background: #fff; border-right: 1px solid #e8ecf0;
display: flex; flex-direction: column; padding: 20px 0;
}
.store-sidebar-title { font-size: 14px; font-weight: 600; color: #1a1a2e; padding: 0 20px 12px; border-bottom: 1px solid #f0f0f0; }
.store-filter { padding: 10px 20px; cursor: pointer; font-size: 13px; color: #555; transition: all 0.15s; }
.store-filter:hover { background: #f8f9fa; }
.store-filter.active { background: #eef4fb; color: #4a90d9; font-weight: 500; border-right: 3px solid #4a90d9; }
.store-filter .count { float: right; background: #f0f0f0; border-radius: 10px; padding: 1px 8px; font-size: 11px; }
.store-filter.active .count { background: #4a90d9; color: #fff; }
.store-main { flex: 1; overflow-y: auto; padding: 24px 32px; background: #f9fafb; }
.store-header { margin-bottom: 24px; }
.store-header h2 { font-size: 26px; font-weight: 600; color: #1a1a2e; }
.store-header p { color: #888; font-size: 14px; margin-top: 4px; }
.store-search { margin-bottom: 20px; }
.store-search input {
width: 100%; max-width: 400px; padding: 10px 16px; border: 1px solid #e0e0e0;
border-radius: 10px; font-size: 14px; outline: none; box-sizing: border-box;
}
.store-search input:focus { border-color: #4a90d9; }
.store-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.store-card { background: #fff; border-radius: 14px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03); display: flex; flex-direction: column; gap: 12px; }
.store-card-header { display: flex; justify-content: space-between; align-items: flex-start; }
.store-card-name { font-size: 16px; font-weight: 600; color: #1a1a2e; }
.store-card-version { font-size: 12px; color: #999; background: #f0f0f0; padding: 2px 8px; border-radius: 10px; }
.store-card-desc { font-size: 13px; color: #666; flex: 1; line-height: 1.5; }
.store-card-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.store-card-tag { font-size: 11px; padding: 3px 8px; background: #f0f5fc; color: #4a90d9; border-radius: 6px; }
.store-card-tag.installed { background: #e8f8ef; color: #2ecc71; }
.install-btn {
padding: 8px 18px; border: none; border-radius: 8px; font-size: 13px;
font-weight: 500; cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.install-btn.install { background: #4a90d9; color: #fff; }
.install-btn.install:hover { background: #3a7bc8; }
.install-btn.installed { background: #e8f8ef; color: #2ecc71; cursor: default; }
.install-btn:disabled { background: #ccc; cursor: not-allowed; }
.store-empty { text-align: center; padding: 60px 20px; color: #999; }
.store-empty i { font-size: 48px; margin-bottom: 12px; display: block; }
.store-loading { text-align: center; padding: 80px 20px; color: #666; }
.store-loading i { font-size: 36px; animation: spin 1s linear infinite; display: block; margin-bottom: 16px; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<!-- 左栏:分类 -->
<div class="store-sidebar">
<div class="store-sidebar-title">分类</div>
<div class="store-filter" :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">
全部插件 <span class="count" x-text="plugins.length"></span>
</div>
<div class="store-filter" :class="{ active: activeFilter === 'available' }" @click="activeFilter = 'available'">
可安装 <span class="count" x-text="plugins.filter(p => !p.is_installed).length"></span>
</div>
<div class="store-filter" :class="{ active: activeFilter === 'installed' }" @click="activeFilter = 'installed'">
已安装 <span class="count" x-text="plugins.filter(p => p.is_installed).length"></span>
</div>
<div class="store-filter" :class="{ active: activeFilter === 'configurable' }" @click="activeFilter = 'configurable'">
可配置 <span class="count" x-text="plugins.filter(p => p.has_config).length"></span>
</div>
</div>
<!-- 右栏:插件卡片列表 -->
<div class="store-main">
<div class="store-header">
<h2>插件商店</h2>
<p>浏览并安装插件来扩展功能</p>
</div>
<!-- 加载中状态 -->
<div class="store-loading" x-show="!loaded && !loadError">
<i class="ri-loader-4-line"></i>
<p>正在加载插件列表...</p>
</div>
<!-- 加载失败状态 -->
<div class="store-empty" x-show="loadError">
<i class="ri-error-warning-line"></i>
<p>加载失败,请稍后重试</p>
</div>
<div class="store-search" x-show="loaded && !loadError">
<input type="text" placeholder="搜索插件名称或描述..." x-model="searchQuery" />
</div>
<div class="store-grid" x-show="loaded && !loadError && filteredPlugins.length > 0">
<template x-for="plugin in filteredPlugins" :key="plugin.full_name">
<div class="store-card">
<div class="store-card-header">
<div>
<div class="store-card-name" x-text="plugin.metadata.name || plugin.name"></div>
<div class="store-card-version" x-text="(plugin.metadata.version ? 'v' + plugin.metadata.version : '') + (plugin.author ? ' · ' + plugin.author : '')"></div>
</div>
<button class="install-btn" :class="plugin.is_installed ? 'installed' : 'install'"
@click="!plugin.is_installed && installPlugin(plugin)"
:disabled="loading">
<span x-show="!plugin.is_installed && !loading">📦 安装</span>
<span x-show="plugin.is_installed"> 已安装</span>
<span x-show="loading">...</span>
</button>
</div>
<div class="store-card-desc" x-text="plugin.metadata.description || '暂无描述'"></div>
<div class="store-card-tags">
<template x-for="dep in (plugin.dependencies || [])" :key="dep">
<span class="store-card-tag" x-text="'🔌 ' + dep"></span>
</template>
<span class="store-card-tag" x-show="plugin.has_config">⚙️ 可配置</span>
</div>
</div>
</template>
</div>
<div class="store-empty" x-show="loaded && !loadError && filteredPlugins.length === 0">
<i class="ri-store-2-line"></i>
<p x-text="plugins.length === 0 ? '无法连接 Gitee API请检查网络或配置' : '没有找到匹配的插件'"></p>
</div>
</div>
<script>
function storeApp() {
return {
plugins: [],
searchQuery: '',
activeFilter: 'all',
loading: false,
loaded: false,
loadError: false,
init() { this.loadPlugins(); },
async loadPlugins() {
this.loaded = false;
this.loadError = false;
try {
const res = await fetch('/api/store/remote');
if (!res.ok) throw new Error('API 返回错误');
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
this.plugins = data;
} else {
this.loadError = true;
}
} catch (e) {
console.error('获取远程插件失败:', e);
this.loadError = true;
}
this.loaded = true;
},
get filteredPlugins() {
let list = this.plugins;
if (this.activeFilter === 'available') list = list.filter(p => !p.is_installed);
else if (this.activeFilter === 'installed') list = list.filter(p => p.is_installed);
else if (this.activeFilter === 'configurable') list = list.filter(p => p.has_config);
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
list = list.filter(p =>
(p.metadata.name || '').toLowerCase().includes(q) ||
(p.metadata.description || '').toLowerCase().includes(q) ||
p.name.toLowerCase().includes(q)
);
}
return list;
},
async installPlugin(plugin) {
this.loading = true;
try {
const res = await fetch('/api/store/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: plugin.name, author: plugin.author })
});
const data = await res.json();
if (data.ok) {
plugin.is_installed = true;
alert('✅ 安装成功,请重启 FutureOSS 以启用插件');
} else {
alert('❌ 安装失败: ' + (data.error || '未知错误'));
}
} catch (e) { alert('❌ 网络错误'); }
this.loading = false;
}
};
}
</script>
</div>