### User query:
这次提交的标题 ### Changes made to the code/files: Title: Remove PHP dependencies and refactor UI rendering to pure HTML templates Key features implemented: - Refactored dashboard plugin to remove PHP dependency and implement pure HTML/CSS/JS template rendering - Updated log-terminal plugin to replace PHP-based UI with native Python HTML template generation - Modified package manager plugin to eliminate PHP view files and use direct HTML string construction - Removed all PHP view template files across dashboard, log-terminal, and package manager plugins - Updated .gitignore to include additional build artifacts and environment files - Enhanced dashboard with real-time metrics, system information, and network statistics without external PHP processing The overall change migrates the system from requiring PHP for UI rendering to using pure Python-based HTML template generation, simplifying deployment and removing external dependencies.
This commit is contained in:
Binary file not shown.
@@ -104,46 +104,187 @@ class PkgManagerPlugin(Plugin):
|
||||
# ==================== 页面渲染 ====================
|
||||
|
||||
def _packages_content(self) -> str:
|
||||
return self._render_php_view('packages.php', {'pageTitle': '插件管理'})
|
||||
"""渲染插件管理页面 - 纯 HTML/Python 模板"""
|
||||
try:
|
||||
# 获取已安装的插件列表
|
||||
plugins = self._get_installed_plugins()
|
||||
plugin_rows = ""
|
||||
for pkg_name, info in plugins.items():
|
||||
status_class = "success" if info.get('enabled', False) else "secondary"
|
||||
status_text = "已启用" if info.get('enabled', False) else "已禁用"
|
||||
plugin_rows += f"""
|
||||
<tr>
|
||||
<td>{pkg_name}</td>
|
||||
<td>{info.get('version', '未知')}</td>
|
||||
<td>{info.get('author', '未知')}</td>
|
||||
<td><span class="badge badge-{status_class}">{status_text}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="togglePlugin('{pkg_name}')">切换状态</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="uninstallPlugin('{pkg_name}')">卸载</button>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>插件管理</title>
|
||||
<link rel="stylesheet" href="/assets/remixicon.css">
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f6fa; padding: 20px; }}
|
||||
.container {{ max-width: 1400px; margin: 0 auto; }}
|
||||
.card {{ background: white; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 20px; }}
|
||||
.card-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }}
|
||||
.card-title {{ font-size: 18px; font-weight: 600; color: #2c3e50; }}
|
||||
.btn {{ padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.3s; }}
|
||||
.btn-primary {{ background: #3498db; color: white; }}
|
||||
.btn-primary:hover {{ background: #2980b9; }}
|
||||
.btn-danger {{ background: #e74c3c; color: white; }}
|
||||
.btn-danger:hover {{ background: #c0392b; }}
|
||||
.btn-sm {{ padding: 4px 8px; font-size: 12px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ecf0f1; }}
|
||||
th {{ background: #f8f9fa; font-weight: 600; color: #2c3e50; }}
|
||||
tr:hover {{ background: #f8f9fa; }}
|
||||
.badge {{ padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }}
|
||||
.badge-success {{ background: #d5f5e3; color: #27ae60; }}
|
||||
.badge-secondary {{ background: #e5e7eb; color: #6b7280; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title"><i class="ri-plug-line"></i> 插件管理</h2>
|
||||
<button class="btn btn-primary" onclick="location.href='/store'"><i class="ri-store-line"></i> 前往商店</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>插件名称</th>
|
||||
<th>版本</th>
|
||||
<th>作者</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plugin_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function togglePlugin(name) {{
|
||||
fetch('/api/plugins/toggle', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{plugin: name}})
|
||||
}}).then(() => location.reload());
|
||||
}}
|
||||
function uninstallPlugin(name) {{
|
||||
if (confirm('确定要卸载 ' + name + ' 吗?')) {{
|
||||
fetch('/api/plugins/uninstall', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{plugin: name}})
|
||||
}}).then(() => location.reload());
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return html
|
||||
except Exception as e:
|
||||
return f"<p>插件管理页面渲染出错:{{e}}</p>"
|
||||
|
||||
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')
|
||||
"""渲染插件商店页面 - 纯 HTML/Python 模板"""
|
||||
try:
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"<?php\n{php_vars}\n?>\n{php_content}")
|
||||
# 获取可用插件列表
|
||||
available = self._get_available_plugins()
|
||||
installed = self._get_installed_plugins()
|
||||
plugin_cards = ""
|
||||
for pkg_name, info in available.items():
|
||||
is_installed = pkg_name in installed
|
||||
action_btn = f'<button class="btn btn-success" onclick="installPlugin(\'{pkg_name}\')">安装</button>' if not is_installed else '<button class="btn btn-secondary" disabled>已安装</button>'
|
||||
plugin_cards += f"""
|
||||
<div class="plugin-card">
|
||||
<div class="plugin-icon"><i class="ri-plug-line"></i></div>
|
||||
<h3>{info.get('name', pkg_name)}</h3>
|
||||
<p class="plugin-desc">{info.get('description', '暂无描述')}</p>
|
||||
<div class="plugin-meta">
|
||||
<span>版本:{info.get('version', '未知')}</span>
|
||||
<span>作者:{info.get('author', '未知')}</span>
|
||||
</div>
|
||||
<div class="plugin-actions">
|
||||
{action_btn}
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>插件商店</title>
|
||||
<link rel="stylesheet" href="/assets/remixicon.css">
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f6fa; padding: 20px; }}
|
||||
.container {{ max-width: 1400px; margin: 0 auto; }}
|
||||
.card {{ background: white; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 20px; }}
|
||||
.card-header {{ margin-bottom: 20px; }}
|
||||
.card-title {{ font-size: 18px; font-weight: 600; color: #2c3e50; }}
|
||||
.btn {{ padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.3s; }}
|
||||
.btn-success {{ background: #27ae60; color: white; }}
|
||||
.btn-success:hover {{ background: #229954; }}
|
||||
.btn-secondary {{ background: #95a5a6; color: white; cursor: not-allowed; }}
|
||||
.plugins-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }}
|
||||
.plugin-card {{ background: #f8f9fa; border-radius: 8px; padding: 20px; transition: transform 0.3s; }}
|
||||
.plugin-card:hover {{ transform: translateY(-5px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); }}
|
||||
.plugin-icon {{ width: 48px; height: 48px; background: #3498db; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; font-size: 24px; margin-bottom: 15px; }}
|
||||
.plugin-card h3 {{ font-size: 16px; color: #2c3e50; margin-bottom: 10px; }}
|
||||
.plugin-desc {{ color: #7f8c8d; font-size: 14px; margin-bottom: 15px; line-height: 1.5; }}
|
||||
.plugin-meta {{ display: flex; justify-content: space-between; font-size: 12px; color: #95a5a6; margin-bottom: 15px; }}
|
||||
.plugin-actions {{ display: flex; gap: 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title"><i class="ri-store-line"></i> 插件商店</h2>
|
||||
</div>
|
||||
<div class="plugins-grid">
|
||||
{plugin_cards}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function installPlugin(name) {{
|
||||
fetch('/api/plugins/install', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{plugin: name}})
|
||||
}}).then(r => r.json()).then(data => {{
|
||||
if (data.success) {{
|
||||
alert('安装成功!');
|
||||
location.reload();
|
||||
}} else {{
|
||||
alert('安装失败:' + data.error);
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return html
|
||||
except Exception as e:
|
||||
return f"<p>插件商店页面渲染出错:{{e}}</p>"
|
||||
|
||||
result = subprocess.run(
|
||||
["php", "-f", tmp_file],
|
||||
capture_output=True, text=True, timeout=10, cwd=views_dir,
|
||||
encoding='utf-8', errors='replace'
|
||||
)
|
||||
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 处理 ====================
|
||||
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
<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>
|
||||
@@ -1,197 +0,0 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user