feat: add controlled plugin marketplace lifecycle
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
let csrf = ''
|
||||
let pendingToken = ''
|
||||
let configPluginId = ''
|
||||
let installedPlugins = new Map()
|
||||
const notice = (message, error = false) => {
|
||||
const el = $('#notice'); el.textContent = message || ''; el.className = error ? 'notice error' : 'notice'
|
||||
}
|
||||
@@ -41,25 +42,54 @@
|
||||
const logout = async () => { try { await api('/logout', { method: 'POST' }) } catch (_) {} csrf = ''; setLoggedIn(null); $('#login-form').hidden = false; $('#twofa-form').hidden = true }
|
||||
const badge = (state) => `<span class="badge badge-${String(state || '').replace(/[^a-z_]/g, '')}">${escapeHtml(state || 'unknown')}</span>`
|
||||
const escapeHtml = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]))
|
||||
const marketplaceInstalled = (item) => {
|
||||
const installed = installedPlugins.get(String(item.plugin_id || ''))
|
||||
return !!(item.installed || installed)
|
||||
}
|
||||
const marketplaceStatus = (item) => marketplaceInstalled(item)
|
||||
? `<span class="market-status">已入库 · ${escapeHtml(item.installed_status === 'installed' ? '运行中' : item.installed_status === 'stopped' ? '已停用' : item.installed_state === 'error' ? '启用失败' : item.installed_state === 'incompatible' ? 'Core 不兼容' : '待启用')}</span>`
|
||||
: '<span class="market-status market-status-available">可安装</span>'
|
||||
const loadMarketplace = async () => {
|
||||
const data = await api('/api/marketplace'); const root = $('#marketplace'); root.textContent = ''
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
if (!items.length) { root.innerHTML = '<div class="empty">暂无可用插件</div>'; return }
|
||||
for (const item of items) {
|
||||
const pluginId = String(item.plugin_id || ''); const version = String(item.version || '')
|
||||
const installed = marketplaceInstalled(item); const sameVersion = installed && String(item.installed_version || '') === version; const card = document.createElement('article'); card.className = 'market-card'
|
||||
const marketButtonLabel = installed ? (sameVersion ? '已入库' : '请在已入库卡片中升级') : '下载并入库'
|
||||
card.innerHTML = `<div class="market-title"><div><h3>${escapeHtml(item.name || pluginId)}</h3><p class="muted mono">${escapeHtml(pluginId)} · v${escapeHtml(version)}</p></div>${marketplaceStatus(item)}</div><p class="market-description">${escapeHtml(item.description || '由受控插件目录提供的 Business Plugin')}</p><dl class="market-meta"><div><dt>发布者</dt><dd>${escapeHtml(item.publisher || item.publisher_key_id || '-')}</dd></div><div><dt>兼容性</dt><dd>${escapeHtml(item.compatibility && item.compatibility.status || item.compatibility_status || 'unknown')}</dd></div><div><dt>包 SHA-256</dt><dd class="mono">${escapeHtml(item.archive_sha256 || item.sha256 || '-')}</dd></div></dl><div class="market-actions"><button data-market-action="install" data-id="${escapeHtml(pluginId)}" data-version="${escapeHtml(version)}" class="button" ${installed ? 'disabled' : ''}>${marketButtonLabel}</button></div>`
|
||||
root.appendChild(card)
|
||||
}
|
||||
}
|
||||
const loadPlugins = async () => {
|
||||
const data = await api('/api/plugins'); const root = $('#plugins'); root.textContent = ''
|
||||
installedPlugins = new Map((data.items || []).map((plugin) => [String(plugin.plugin_id || ''), plugin]))
|
||||
if (!data.items || !data.items.length) { root.innerHTML = '<div class="empty">还没有登记业务插件</div>'; return }
|
||||
for (const plugin of data.items) {
|
||||
const card = document.createElement('article'); card.className = 'plugin-card'
|
||||
card.innerHTML = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div>${badge(plugin.state)}</div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>卸载</button></div>`
|
||||
const staged = plugin.installation_status === 'staged' || plugin.state === 'disabled' || plugin.state === 'incompatible'
|
||||
const installedLabel = plugin.state === 'healthy' || plugin.state === 'enabled' ? '运行中' : plugin.installation_status === 'stopped' ? '已停用' : plugin.state === 'incompatible' ? 'Core 不兼容' : plugin.state === 'error' ? '启用失败' : staged ? '已入库 · 待启用' : ''
|
||||
card.innerHTML = `<div class="plugin-title"><div><h3>${escapeHtml(plugin.name)}</h3><p class="muted mono">${escapeHtml(plugin.plugin_id)} · v${escapeHtml(plugin.version)}</p></div><div class="state-stack">${badge(plugin.state)}${installedLabel ? `<span class="pending-label">${installedLabel}</span>` : ''}</div></div><dl class="meta"><div><dt>能力</dt><dd>${(plugin.capabilities || []).map(escapeHtml).join(', ') || '未声明'}</dd></div><div><dt>活动 revision</dt><dd class="mono">${escapeHtml(plugin.active_revision || '-')}</dd></div><div><dt>Core 兼容性</dt><dd>${escapeHtml((plugin.compatibility || {}).status || 'unknown')}</dd></div></dl><p class="plugin-error">${escapeHtml(plugin.last_error || '')}</p><div class="card-actions"><button data-action="config" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">配置</button><label class="file-button">升级<input data-action="upgrade-file" data-id="${escapeHtml(plugin.plugin_id)}" type="file" accept=".zip,.s2plugin,application/zip"></label><button data-action="enable" data-id="${escapeHtml(plugin.plugin_id)}" class="button" ${plugin.state === 'healthy' ? 'disabled' : ''}>启用</button><button data-action="disable" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary" ${plugin.state !== 'healthy' ? 'disabled' : ''}>停用</button><button data-action="rollback" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">回滚</button><button data-action="menu-preview" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">菜单预览</button><button data-action="menu-apply" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-secondary">应用菜单</button><button data-action="uninstall" data-id="${escapeHtml(plugin.plugin_id)}" class="button button-danger" ${plugin.state === 'healthy' ? 'disabled' : ''}>删除</button></div>`
|
||||
root.appendChild(card)
|
||||
}
|
||||
}
|
||||
const loadAudit = async () => { const data = await api('/api/audit'); const root = $('#audit'); root.textContent = ''; for (const item of (data.items || []).slice().reverse()) { const row = document.createElement('div'); row.className = 'audit-row'; row.innerHTML = `<span>${escapeHtml(item.action)}</span><span class="mono">${escapeHtml(item.plugin_id || '-')}</span><span>${escapeHtml(item.result)}</span><time>${escapeHtml(item.time)}</time>`; root.appendChild(row) } }
|
||||
const loadAll = async () => { try { await Promise.all([loadPlugins(), loadAudit()]); notice('') } catch (error) { notice(error.message, true) } }
|
||||
const mutate = async (action, id) => { const key = `${action}-${id}-${Date.now()}`; try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': key } }); notice(`操作已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
const loadAll = async () => { try { await loadPlugins(); await Promise.all([loadMarketplace(), loadAudit()]); notice('') } catch (error) { notice(error.message, true) } }
|
||||
const mutate = async (action, id) => { const key = `${action}-${id}-${Date.now()}`; try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': key } }); notice(`操作已提交:${result.operation_id || result.state}${result.warning ? `;${result.warning}` : ''}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
const openConfig = async (id) => { configPluginId = id; $('#config-plugin').textContent = id; $('#config-error').textContent = ''; const form = $('#config-form'); form.elements.service_url.value = ''; form.elements.public_url.value = ''; try { const data = await api(`/api/plugins/${encodeURIComponent(id)}/config`); form.elements.service_url.value = data.endpoint || ''; if (data.config && typeof data.config.public_url === 'string') form.elements.public_url.value = data.config.public_url; $('#config-dialog').showModal() } catch (error) { notice(error.message, true) } }
|
||||
const saveConfig = async (event) => { event.preventDefault(); const form = event.currentTarget; const body = { service_url: form.elements.service_url.value.trim(), public_url: form.elements.public_url.value.trim() }; try { const result = await api(`/api/plugins/${encodeURIComponent(configPluginId)}/config`, { method: 'PUT', headers: { 'Idempotency-Key': `config-${configPluginId}-${Date.now()}` }, body: JSON.stringify(body) }); $('#config-dialog').close(); notice(`配置已保存:${result.operation_id || result.state}`); await loadAll() } catch (error) { $('#config-error').textContent = error.message } }
|
||||
const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`安装已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
const upload = async (file) => { const form = new FormData(); form.append('package', file); try { const result = await api('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': `install-${Date.now()}` }, body: form }); notice(`插件已入库,待手动启用:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
const uploadUpgrade = async (id, file) => { const form = new FormData(); form.append('package', file); try { const result = await api(`/api/plugins/${encodeURIComponent(id)}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': `upgrade-${id}-${Date.now()}` }, body: form }); notice(`升级已提交:${result.operation_id || result.state}`); await loadAll() } catch (error) { notice(error.message, true) } }
|
||||
$('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close())
|
||||
const installFromMarketplace = async (button) => {
|
||||
const pluginId = button.dataset.id; const version = button.dataset.version
|
||||
if (!pluginId || !version || button.disabled) return
|
||||
button.disabled = true
|
||||
try { const result = await api('/api/marketplace/install', { method: 'POST', headers: { 'Idempotency-Key': `marketplace-install-${pluginId}-${version}-${Date.now()}` }, body: JSON.stringify({ plugin_id: pluginId, version }) }); notice(`插件已入库,待手动启用:${result.operation_id || result.state}`); await loadAll() } catch (error) { button.disabled = false; notice(error.message, true) }
|
||||
}
|
||||
$('#login-form').addEventListener('submit', login); $('#twofa-form').addEventListener('submit', login2fa); $('#logout').addEventListener('click', logout); $('#refresh').addEventListener('click', loadAll); $('#marketplace-refresh').addEventListener('click', loadAll); $('#audit-refresh').addEventListener('click', loadAudit); $('#config-form').addEventListener('submit', saveConfig); $('#config-close').addEventListener('click', () => $('#config-dialog').close()); $('#config-cancel').addEventListener('click', () => $('#config-dialog').close())
|
||||
$('#package-file').addEventListener('change', (event) => { const file = event.target.files[0]; if (file) upload(file); event.target.value = '' })
|
||||
$('#plugins').addEventListener('change', (event) => { const input = event.target.closest('[data-action="upgrade-file"]'); if (!input) return; const file = input.files[0]; if (file) uploadUpgrade(input.dataset.id, file); input.value = '' })
|
||||
$('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } mutate(action, id) })
|
||||
$('#plugins').addEventListener('click', (event) => { const button = event.target.closest('[data-action]'); if (!button || button.disabled) return; const action = button.dataset.action; const id = button.dataset.id; if (action === 'config') { openConfig(id); return } if (action === 'uninstall' && !window.confirm('删除后将移除插件文件,Core 数据不会删除。继续吗?')) return; mutate(action, id) })
|
||||
$('#marketplace').addEventListener('click', (event) => { const button = event.target.closest('[data-market-action="install"]'); if (button) installFromMarketplace(button) })
|
||||
api('/api/me').then((data) => { csrf = data.csrf_token; setLoggedIn(data.user); loadAll() }).catch(() => setLoggedIn(null))
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user