From 1e51acc510f060486f45c1e43814121f423f972c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Thu, 19 Mar 2026 12:06:34 +0800
Subject: [PATCH 1/8] refactor: optimize dashboard skills overview
---
src/lib/skills-catalog.js | 64 +++++++++++++++++++++++++++++++++++++++
src/pages/dashboard.js | 24 ++++++++-------
src/pages/skills.js | 25 ++++++++++-----
3 files changed, 94 insertions(+), 19 deletions(-)
create mode 100644 src/lib/skills-catalog.js
diff --git a/src/lib/skills-catalog.js b/src/lib/skills-catalog.js
new file mode 100644
index 00000000..7e9f92ce
--- /dev/null
+++ b/src/lib/skills-catalog.js
@@ -0,0 +1,64 @@
+import { api } from './tauri-api.js'
+
+const SKILLS_CACHE_TTL_MS = 20_000
+
+let _skillsCache = {
+ data: null,
+ expiresAt: 0,
+ pending: null,
+}
+
+function normalizeSkillsData(data) {
+ return {
+ ...data,
+ skills: Array.isArray(data?.skills) ? data.skills : [],
+ }
+}
+
+export function summarizeSkillsCatalog(data) {
+ const skills = Array.isArray(data?.skills) ? data.skills : []
+ const eligible = skills.filter(s => s.eligible && !s.disabled)
+ const missing = skills.filter(s => !s.eligible && !s.disabled && !s.blockedByAllowlist)
+ const disabled = skills.filter(s => s.disabled)
+ const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
+ return {
+ total: skills.length,
+ eligible: eligible.length,
+ missing: missing.length,
+ disabled: disabled.length,
+ blocked: blocked.length,
+ }
+}
+
+export function getCachedSkillsCatalog() {
+ if (!_skillsCache.data) return null
+ if (Date.now() > _skillsCache.expiresAt) return null
+ return _skillsCache.data
+}
+
+export function invalidateSkillsCatalog() {
+ _skillsCache.expiresAt = 0
+}
+
+export async function loadSkillsCatalog(options = {}) {
+ const force = !!options.force
+ const now = Date.now()
+ if (!force && _skillsCache.data && now <= _skillsCache.expiresAt) {
+ return _skillsCache.data
+ }
+ if (!force && _skillsCache.pending) {
+ return _skillsCache.pending
+ }
+ const request = api.skillsList()
+ .then(normalizeSkillsData)
+ .then(data => {
+ _skillsCache.data = data
+ _skillsCache.expiresAt = Date.now() + SKILLS_CACHE_TTL_MS
+ return data
+ })
+ .finally(() => {
+ if (_skillsCache.pending === request) _skillsCache.pending = null
+ })
+ _skillsCache.pending = request
+ return request
+}
diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js
index b73e1bd9..1f6b7e30 100644
--- a/src/pages/dashboard.js
+++ b/src/pages/dashboard.js
@@ -4,6 +4,7 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { onGatewayChange } from '../lib/app-state.js'
+import { loadSkillsCatalog, summarizeSkillsCatalog } from '../lib/skills-catalog.js'
import { navigate } from '../router.js'
let _unsubGw = null
@@ -80,7 +81,7 @@ async function loadDashboardData(page, fullRefresh = false) {
]), 15000)
const secondaryP = withTimeout(Promise.allSettled([
api.listAgents(),
- api.readMcpConfig(),
+ loadSkillsCatalog({ force: fullRefresh }),
api.listBackups(),
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
@@ -122,15 +123,15 @@ async function loadDashboardData(page, fullRefresh = false) {
renderStatCards(page, services, version, [], config)
- // 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览
- const [agentsRes, mcpRes, backupsRes, statusRes] = await secondaryP
+ // 第二波:Agent、Skills、备份 → 更新卡片 + 渲染总览
+ const [agentsRes, skillsRes, backupsRes, statusRes] = await secondaryP
const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : []
- const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null
+ const skillsData = skillsRes.status === 'fulfilled' ? skillsRes.value : null
const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : []
const statusSummary = statusRes.status === 'fulfilled' ? statusRes.value : null
renderStatCards(page, services, version, agents, config)
- renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary)
+ renderOverview(page, services, skillsData, backups, config, agents, statusSummary)
// 第三波:日志(最低优先级)
const logs = await logsP
@@ -199,10 +200,11 @@ function renderStatCards(page, services, version, agents, config) {
`
}
-function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {
+function renderOverview(page, services, skillsData, backups, config, agents, statusSummary) {
+ services = Array.isArray(services) ? services : []
const containerEl = page.querySelector('#dashboard-overview-container')
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
- const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0
+ const skillSummary = summarizeSkillsCatalog(skillsData)
const formatDate = (timestamp) => {
if (!timestamp) return '——'
@@ -255,12 +257,12 @@ function renderOverview(page, services, mcpConfig, backups, config, agents, stat
-
MCP 工具
-
${mcpCount} 个
-
已挂载扩展
+
Skills
+
${skillSummary.total} 个
+
${skillSummary.eligible} 可用 · ${skillSummary.missing} 缺依赖
diff --git a/src/pages/skills.js b/src/pages/skills.js
index af7b3215..2e7c060b 100644
--- a/src/pages/skills.js
+++ b/src/pages/skills.js
@@ -4,6 +4,7 @@
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
+import { invalidateSkillsCatalog, getCachedSkillsCatalog, loadSkillsCatalog } from '../lib/skills-catalog.js'
let _loadSeq = 0
@@ -51,22 +52,29 @@ export async function render() {
return page
}
-async function loadSkills(page) {
+async function loadSkills(page, options = {}) {
const el = page.querySelector('#skills-tab-installed')
if (!el) return
const seq = ++_loadSeq
+ const force = !!options.force
+ const cached = !force ? getCachedSkillsCatalog() : null
- el.innerHTML = ``
+ if (cached) {
+ renderSkills(el, cached)
+ } else {
+ el.innerHTML = ``
+ }
try {
- const data = await api.skillsList()
+ const data = await loadSkillsCatalog({ force })
if (seq !== _loadSeq) return
renderSkills(el, data)
} catch (e) {
if (seq !== _loadSeq) return
+ if (cached) return
el.innerHTML = `
加载失败: ${esc(e?.message || e)}
请确认 OpenClaw 已安装并可用
@@ -83,7 +91,7 @@ function renderSkills(el, data) {
const disabled = skills.filter(s => s.disabled)
const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
- const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用`
+ const summary = `${eligible.length} 可用 / ${missing.length} 缺依赖 / ${disabled.length} 已禁用 / ${blocked.length} 已阻止`
el.innerHTML = `
'
}
html += ''
@@ -200,6 +228,22 @@ export function renderSidebar(el) {
if (!_delegated) {
_delegated = true
el.addEventListener('click', (e) => {
+ const sectionToggle = e.target.closest('[data-section-toggle]')
+ if (sectionToggle) {
+ const key = sectionToggle.dataset.sectionToggle
+ const sectionEl = e.target.closest('.nav-section[data-section]')
+ const itemsEl = sectionEl?.querySelector('.nav-section-items')
+ const nextOpen = sectionEl?.classList.contains('collapsed')
+ if (sectionEl) {
+ sectionEl.classList.toggle('expanded', !!nextOpen)
+ sectionEl.classList.toggle('collapsed', !nextOpen)
+ }
+ if (itemsEl) itemsEl.style.display = nextOpen ? '' : 'none'
+ const chevron = sectionToggle.querySelector('.nav-section-chevron')
+ if (chevron) chevron.textContent = nextOpen ? '−' : '+'
+ if (key) _setSidebarGroupOpen(key, !!nextOpen)
+ return
+ }
// 导航点击
const navItem = e.target.closest('.nav-item[data-route]')
if (navItem) {
diff --git a/src/style/layout.css b/src/style/layout.css
index 77a84c3f..72629dc1 100644
--- a/src/style/layout.css
+++ b/src/style/layout.css
@@ -19,7 +19,8 @@
#sidebar.sidebar-collapsed .nav-section-title,
#sidebar.sidebar-collapsed .nav-item span,
#sidebar.sidebar-collapsed .sidebar-meta,
-#sidebar.sidebar-collapsed .instance-switcher {
+#sidebar.sidebar-collapsed .instance-switcher,
+#sidebar.sidebar-collapsed .nav-section-toggle {
display: none;
}
#sidebar.sidebar-collapsed .sidebar-header {
@@ -247,13 +248,44 @@
margin-bottom: var(--space-md);
}
+.nav-section-toggle {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-sm);
+ padding: 6px var(--space-sm);
+ margin-bottom: var(--space-xs);
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+}
+
+.nav-section-toggle:hover {
+ background: var(--bg-glass-hover);
+ color: var(--text-secondary);
+}
+
.nav-section-title {
font-size: var(--font-size-xs);
- color: var(--text-tertiary);
+ color: inherit;
text-transform: uppercase;
letter-spacing: 0.5px;
- padding: var(--space-sm) var(--space-sm);
- margin-bottom: var(--space-xs);
+ text-align: left;
+}
+
+.nav-section-chevron {
+ min-width: 16px;
+ text-align: center;
+ font-size: 14px;
+ line-height: 1;
+}
+
+.nav-section-items {
+ display: block;
}
.nav-item {
From c0d68a1425eccd8c049e8c052bed40ab909126c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Thu, 19 Mar 2026 12:23:40 +0800
Subject: [PATCH 3/8] refactor: layer cloudflared access form
---
src/pages/settings.js | 304 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 304 insertions(+)
diff --git a/src/pages/settings.js b/src/pages/settings.js
index 123d5c77..4f3e8c76 100644
--- a/src/pages/settings.js
+++ b/src/pages/settings.js
@@ -142,6 +142,12 @@ async function loadRegistry(page) {
// ===== 事件绑定 =====
function bindEvents(page) {
+ page.addEventListener('change', (e) => {
+ if (e.target?.matches?.('[data-name="cloudflared-mode"], [data-name="cloudflared-expose"], [data-name="cloudflared-port"]')) {
+ syncCloudflaredFormState(page)
+ }
+ })
+
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
@@ -244,3 +250,301 @@ async function handleSaveRegistry(page) {
await api.setNpmRegistry(registry)
toast('npm 源已保存', 'success')
}
+
+// ===== Cloudflared 公网访问 =====
+
+function getCloudflaredForm(page) {
+ const mode = page.querySelector('[data-name="cloudflared-mode"]')?.value || 'quick'
+ const exposeTarget = page.querySelector('[data-name="cloudflared-expose"]')?.value || 'gateway'
+ const customPort = Number(page.querySelector('[data-name="cloudflared-port"]')?.value || 0)
+ const useHttp2 = !!page.querySelector('[data-name="cloudflared-http2"]')?.checked
+ const tunnelName = (page.querySelector('[data-name="cloudflared-tunnel"]')?.value || '').trim()
+ const hostname = (page.querySelector('[data-name="cloudflared-hostname"]')?.value || '').trim()
+ return { mode, exposeTarget, customPort, useHttp2, tunnelName, hostname }
+}
+
+function resolveExposePort(form) {
+ if (form.exposeTarget === 'webui') return 1420
+ if (form.exposeTarget === 'custom') return form.customPort || 18789
+ return 18789
+}
+
+function syncCloudflaredFormState(page) {
+ const form = getCloudflaredForm(page)
+ const modeBlocks = page.querySelectorAll('[data-cloudflared-mode-block]')
+ const exposeBlocks = page.querySelectorAll('[data-cloudflared-expose-block]')
+ modeBlocks.forEach(node => {
+ node.style.display = node.dataset.cloudflaredModeBlock === form.mode ? '' : 'none'
+ })
+ exposeBlocks.forEach(node => {
+ node.style.display = node.dataset.cloudflaredExposeBlock === form.exposeTarget ? '' : 'none'
+ })
+ const resolvedPortEl = page.querySelector('[data-cloudflared-resolved-port]')
+ if (resolvedPortEl) resolvedPortEl.textContent = String(resolveExposePort(form))
+}
+
+async function loadCloudflared(page) {
+ const el = page.querySelector('#cloudflared-bar')
+ if (!el) return
+
+ const cfg = await api.readPanelConfig()
+ if (!cfg.cloudflared || typeof cfg.cloudflared !== 'object') {
+ cfg.cloudflared = { mode: 'quick', exposeTarget: 'gateway', customPort: '', useHttp2: true, tunnelName: '', hostname: '' }
+ await api.writePanelConfig(cfg)
+ }
+ const saved = cfg.cloudflared || {}
+ const status = await api.cloudflaredGetStatus().catch(() => ({ installed: false, running: false }))
+
+ const mode = saved.mode || 'quick'
+ const exposeTarget = saved.exposeTarget || 'gateway'
+ const customPort = saved.customPort || ''
+ const useHttp2 = saved.useHttp2 !== false
+ const tunnelName = saved.tunnelName || ''
+ const hostname = saved.hostname || ''
+
+ el.innerHTML = `
+
+
+
+
${status.running ? '运行中' : '未运行'}
+
版本 ${escapeHtml(status.version || '未知')}
+
+
+
+
${mode === 'named' ? '命名隧道' : '快速隧道'}
+
暴露 ${exposeTarget === 'gateway' ? 'Gateway' : exposeTarget === 'webui' ? 'Web UI' : '自定义端口'}
+
+
+
+
${resolveExposePort({ mode, exposeTarget, customPort, useHttp2, tunnelName, hostname })}
+
启动时传给 cloudflared
+
+
+
+
${status.url ? '已生成' : '未生成'}
+
+
+
+
+
+
+
+ ${status.running
+ ? ''
+ : ''
+ }
+
+
+
+
+
+
1. 选择暴露目标
+
+
+
推荐默认暴露 Gateway。选择 Gateway 时,会自动把 Cloudflare URL 写入 gateway.controlUi.allowedOrigins。
+
+
+
固定暴露 OpenClaw Gateway,端口 18789,无需额外输入。
+
+
+
固定暴露 ClawPanel Web UI,端口 1420,适合只开放管理面板。
+
+
+
+
只有在“自定义端口”模式下这里才生效。
+
+
+
+
+
2. 选择隧道模式
+
+
+
+
+
+
快速隧道无需隧道名和域名,适合临时开放,启动后自动生成公网地址。
+
+
+
+
+
+ 推荐顺序:安装 → 登录 Cloudflare → 选择暴露目标 → 选择隧道模式 → 保存设置 → 启动公网访问。
+
+ `
+ syncCloudflaredFormState(page)
+}
+
+async function handleCloudflaredSave(page) {
+ const cfg = await api.readPanelConfig()
+ const form = getCloudflaredForm(page)
+ cfg.cloudflared = {
+ mode: form.mode,
+ exposeTarget: form.exposeTarget,
+ customPort: form.customPort,
+ useHttp2: form.useHttp2,
+ tunnelName: form.tunnelName,
+ hostname: form.hostname,
+ }
+ await api.writePanelConfig(cfg)
+ toast('Cloudflared 设置已保存', 'success')
+}
+
+async function handleCloudflaredInstall(page) {
+ await api.cloudflaredInstall()
+ await loadCloudflared(page)
+ toast('Cloudflared 已安装', 'success')
+}
+
+async function handleCloudflaredLogin(page) {
+ await api.cloudflaredLogin()
+ await loadCloudflared(page)
+ toast('Cloudflared 登录完成', 'success')
+}
+
+async function handleCloudflaredStart(page) {
+ const form = getCloudflaredForm(page)
+ const port = resolveExposePort(form)
+ await handleCloudflaredSave(page)
+ await api.cloudflaredStart({
+ mode: form.mode,
+ port,
+ use_http2: form.useHttp2,
+ tunnel_name: form.tunnelName || null,
+ hostname: form.hostname || null,
+ add_allowed_origins: true,
+ expose_target: form.exposeTarget,
+ })
+ await loadCloudflared(page)
+ toast('Cloudflared 已启动', 'success')
+}
+
+async function handleCloudflaredStop(page) {
+ await api.cloudflaredStop()
+ await loadCloudflared(page)
+ toast('Cloudflared 已停止', 'success')
+}
+
+// ===== OpenClaw CLI =====
+
+async function loadOpenclawCli(page) {
+ const bar = page.querySelector('#openclaw-bar')
+ if (!bar) return
+ try {
+ const [cfg, services] = await Promise.all([
+ api.readPanelConfig(),
+ api.getServicesStatus(),
+ ])
+ const svc = Array.isArray(services)
+ ? services.find(s => s.label === 'ai.openclaw.gateway' || s.id === 'ai.openclaw.gateway' || s.name === 'ai.openclaw.gateway' || s.label === 'openclaw' || s.id === 'openclaw')
+ : null
+ const overridePath = cfg?.openclawPath || ''
+ const cliMeta = buildOpenclawCliMeta(svc, { overridePath })
+ const detectedPath = cliMeta.path || ''
+ const detectedVersion = cliMeta.version || ''
+ const candidates = Array.isArray(svc?.cli_candidates) ? svc.cli_candidates : []
+ const selectedValue = overridePath || detectedPath || ''
+
+ bar.innerHTML = `
+
+
+
+ ${escapeHtml(cliMeta.statusLabel)}
+ ${detectedVersion ? `CLI 版本: ${escapeHtml(detectedVersion)}` : ''}
+ 路径来源: ${escapeHtml(cliMeta.pathSourceLabel)}
+ 路径策略: ${escapeHtml(cliMeta.strategyLabel)}
+
+ ${detectedPath ? `
CLI 路径: ${escapeHtml(detectedPath)}
` : ''}
+ ${detectedVersion ? `
版本来源: ${escapeHtml(cliMeta.versionSourceLabel)}
` : ''}
+
+
+
+
+
+
+
+
+
+ 保存路径后将优先使用该路径检测与启动 Gateway。若检测到多条 CLI 路径,可先在下拉框中选中再保存。清除覆盖会回退到自动检测。
+
+
+ `
+
+ const candidateSelect = bar.querySelector('[data-name="openclaw-candidate"]')
+ const pathInput = bar.querySelector('[data-name="openclaw-path"]')
+ candidateSelect?.addEventListener('change', () => {
+ if (candidateSelect.value) pathInput.value = candidateSelect.value
+ })
+ } catch (e) {
+ bar.innerHTML = `
加载失败: ${escapeHtml(String(e))}
`
+ }
+}
+
+async function handleOpenclawSave(page) {
+ const candidate = page.querySelector('[data-name="openclaw-candidate"]')
+ const input = page.querySelector('[data-name="openclaw-path"]')
+ const inputValue = String(input?.value || '').trim()
+ const candidateValue = String(candidate?.value || '').trim()
+ const value = inputValue || candidateValue
+ const cfg = await api.readPanelConfig()
+ if (!value) {
+ delete cfg.openclawPath
+ await api.writePanelConfig(cfg)
+ toast('路径为空,已清除覆盖', 'info')
+ await loadOpenclawCli(page)
+ return
+ }
+ cfg.openclawPath = value
+ await api.writePanelConfig(cfg)
+ toast('OpenClaw 路径已保存', 'success')
+ await loadOpenclawCli(page)
+}
+
+async function handleOpenclawClear(page) {
+ const cfg = await api.readPanelConfig()
+ delete cfg.openclawPath
+ await api.writePanelConfig(cfg)
+ toast('OpenClaw 路径覆盖已清除', 'success')
+ await loadOpenclawCli(page)
+}
+
+async function handleOpenclawSetup() {
+ try {
+ const cfg = await api.readPanelConfig().catch(() => ({}))
+ await api.writePanelConfig({ ...cfg, forceSetup: true, skipSetup: false })
+ window.location.hash = '#/setup'
+ } catch (e) {
+ toast('进入初始化设置失败: ' + (e?.message || e), 'error')
+ }
+}
+>>>>>>> efc1531 (refactor: layer cloudflared access form)
From 92e71e0bc93243136dc1fe2ceb77fbc2c8c8fbe3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Thu, 19 Mar 2026 12:29:58 +0800
Subject: [PATCH 4/8] fix: harden cloudflared form validation
---
src/pages/settings.js | 34 +++++++++++++++++++++++++++++++++-
1 file changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/pages/settings.js b/src/pages/settings.js
index 4f3e8c76..3c68a9c1 100644
--- a/src/pages/settings.js
+++ b/src/pages/settings.js
@@ -269,6 +269,19 @@ function resolveExposePort(form) {
return 18789
}
+function validateCloudflaredForm(form) {
+ if (form.exposeTarget === 'custom' && !(Number(form.customPort) > 0)) {
+ return '自定义端口模式下必须填写有效端口'
+ }
+ if (form.mode === 'named' && !form.tunnelName) {
+ return '命名隧道模式下必须填写隧道名称'
+ }
+ if (form.mode === 'named' && !form.hostname) {
+ return '命名隧道模式下必须填写绑定域名'
+ }
+ return ''
+}
+
function syncCloudflaredFormState(page) {
const form = getCloudflaredForm(page)
const modeBlocks = page.querySelectorAll('[data-cloudflared-mode-block]')
@@ -281,6 +294,20 @@ function syncCloudflaredFormState(page) {
})
const resolvedPortEl = page.querySelector('[data-cloudflared-resolved-port]')
if (resolvedPortEl) resolvedPortEl.textContent = String(resolveExposePort(form))
+ const customPortInput = page.querySelector('[data-name="cloudflared-port"]')
+ if (customPortInput) customPortInput.disabled = form.exposeTarget !== 'custom'
+ const tunnelNameInput = page.querySelector('[data-name="cloudflared-tunnel"]')
+ const hostnameInput = page.querySelector('[data-name="cloudflared-hostname"]')
+ if (tunnelNameInput) tunnelNameInput.disabled = form.mode !== 'named'
+ if (hostnameInput) hostnameInput.disabled = form.mode !== 'named'
+ const validationEl = page.querySelector('[data-cloudflared-validation]')
+ const errorText = validateCloudflaredForm(form)
+ if (validationEl) {
+ validationEl.textContent = errorText || '当前配置可直接保存;启动前会按你的选择自动计算实际端口。'
+ validationEl.style.color = errorText ? 'var(--warning)' : 'var(--text-tertiary)'
+ }
+ const startBtn = page.querySelector('[data-action="cloudflared-start"]')
+ if (startBtn) startBtn.disabled = !!errorText
}
async function loadCloudflared(page) {
@@ -431,6 +458,12 @@ async function handleCloudflaredLogin(page) {
async function handleCloudflaredStart(page) {
const form = getCloudflaredForm(page)
+ const validationError = validateCloudflaredForm(form)
+ if (validationError) {
+ syncCloudflaredFormState(page)
+ toast(validationError, 'warning')
+ return
+ }
const port = resolveExposePort(form)
await handleCloudflaredSave(page)
await api.cloudflaredStart({
@@ -547,4 +580,3 @@ async function handleOpenclawSetup() {
toast('进入初始化设置失败: ' + (e?.message || e), 'error')
}
}
->>>>>>> efc1531 (refactor: layer cloudflared access form)
From 2b3676be0885f01d1b34db754166b4315600f8a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Thu, 19 Mar 2026 12:32:53 +0800
Subject: [PATCH 5/8] fix: tighten cloudflared action states
---
src/pages/settings.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/pages/settings.js b/src/pages/settings.js
index 3c68a9c1..ba0f55ed 100644
--- a/src/pages/settings.js
+++ b/src/pages/settings.js
@@ -451,6 +451,11 @@ async function handleCloudflaredInstall(page) {
}
async function handleCloudflaredLogin(page) {
+ if (page.__cloudflaredInstalled === false) {
+ toast('请先安装 Cloudflared', 'warning')
+ syncCloudflaredFormState(page)
+ return
+ }
await api.cloudflaredLogin()
await loadCloudflared(page)
toast('Cloudflared 登录完成', 'success')
From de56b798f5660ea5a15cf20bf67e67319112dba4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Thu, 19 Mar 2026 12:35:55 +0800
Subject: [PATCH 6/8] refactor: polish skills overview states
---
src/pages/skills.js | 34 +++++++++++++++++++++++++++++++++-
1 file changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/pages/skills.js b/src/pages/skills.js
index 2e7c060b..ef1014cc 100644
--- a/src/pages/skills.js
+++ b/src/pages/skills.js
@@ -101,10 +101,37 @@ function renderSkills(el, data) {
${!cliAvailable ? '
CLI 不可用,仅显示本地扫描结果' : ''}
+
+
+
+
${skills.length}
+
已扫描本地可见技能
+
+
+
+
${eligible.length}
+
环境与依赖已满足
+
+
+
+
${missing.length + blocked.length}
+
${missing.length} 缺依赖 · ${blocked.length} 已阻止
+
+
+
+
${disabled.length}
+
当前已禁用
+
+
+
共 ${skills.length} 个 Skills: ${summary}
+
+ 当前过滤条件下没有匹配的 Skills
+
+
${eligible.length ? `
✓ 可用 (${eligible.length})
@@ -153,14 +180,19 @@ function renderSkills(el, data) {
// 实时过滤
const input = el.querySelector('#skill-filter-input')
+ const emptyEl = el.querySelector('#skill-filter-empty')
if (input) {
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase()
+ let visibleCount = 0
el.querySelectorAll('.skill-card-item').forEach(card => {
const name = (card.dataset.name || '').toLowerCase()
const desc = (card.dataset.desc || '').toLowerCase()
- card.style.display = (!q || name.includes(q) || desc.includes(q)) ? '' : 'none'
+ const visible = !q || name.includes(q) || desc.includes(q)
+ card.style.display = visible ? '' : 'none'
+ if (visible) visibleCount += 1
})
+ if (emptyEl) emptyEl.style.display = q && visibleCount === 0 ? '' : 'none'
})
}
}
From cd35145ac0b11bb65143f65684583e73a4253a38 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Thu, 19 Mar 2026 12:41:24 +0800
Subject: [PATCH 7/8] refactor: surface cloudflared install status
---
src/pages/settings.js | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/pages/settings.js b/src/pages/settings.js
index ba0f55ed..5d01b420 100644
--- a/src/pages/settings.js
+++ b/src/pages/settings.js
@@ -321,6 +321,7 @@ async function loadCloudflared(page) {
}
const saved = cfg.cloudflared || {}
const status = await api.cloudflaredGetStatus().catch(() => ({ installed: false, running: false }))
+ page.__cloudflaredInstalled = !!status.installed
const mode = saved.mode || 'quick'
const exposeTarget = saved.exposeTarget || 'gateway'
@@ -463,6 +464,11 @@ async function handleCloudflaredLogin(page) {
async function handleCloudflaredStart(page) {
const form = getCloudflaredForm(page)
+ if (page.__cloudflaredInstalled === false) {
+ syncCloudflaredFormState(page)
+ toast('请先安装 Cloudflared', 'warning')
+ return
+ }
const validationError = validateCloudflaredForm(form)
if (validationError) {
syncCloudflaredFormState(page)
From e16325f145b15cb1771265219ef443669bca193f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Thu, 19 Mar 2026 12:43:58 +0800
Subject: [PATCH 8/8] chore: align dashboard skills summary
---
src/pages/dashboard.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js
index 1f6b7e30..eecaf32c 100644
--- a/src/pages/dashboard.js
+++ b/src/pages/dashboard.js
@@ -262,7 +262,7 @@ function renderOverview(page, services, skillsData, backups, config, agents, sta
Skills
${skillSummary.total} 个
-
${skillSummary.eligible} 可用 · ${skillSummary.missing} 缺依赖
+
${skillSummary.eligible} 可用 · ${skillSummary.missing + skillSummary.blocked} 待处理 · ${skillSummary.disabled} 已禁用