mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-08 17:20:01 +08:00
feat(openclaw): P1-0 push.web.* 推送通知 - ClawPanel 关掉也能弹系统通知
OpenClaw 内核已实现 4 个 push.web.* RPC(vapidPublicKey / subscribe / unsubscribe / test),
但 ClawPanel 完全没接。这次打通整条链路:浏览器 → Service Worker → 内核 → 系统通知中心。
## 收益(用户视角)
- ClawPanel 浏览器标签/桌面应用关掉后,Agent / Cron / 渠道消息仍能弹到
Windows / macOS / iOS / Android 系统通知中心
- 锁屏可见,可离线接收(推送服务由浏览器厂商分发)
- 是下一版主推卖点
## 实施(无需新增 Tauri 命令)
- wsClient.request 直接走 WebSocket 调内核 4 个 RPC
## 前端封装 src/lib/push-web.js
- isPushSupported() / pushPermission() / requestPushPermission()
- ensureServiceWorker() 注册 /push-sw.js(幂等)
- subscribePush() 完整流程:权限 → SW → push.web.vapidPublicKey → PushManager.subscribe → push.web.subscribe 上报内核
- unsubscribePush() 本地取消 + 通知内核清理
- sendTestPush(title, body) 调 push.web.test 广播测试
- getCurrentSubscription() / isLocallySubscribed() 状态查询
- urlBase64ToUint8Array / arrayBufferToBase64Url 工具函数
(VAPID 公钥 base64url ↔ 二进制,订阅 keys 编码)
## Service Worker public/push-sw.js
- skipWaiting + clients.claim 立即激活
- push 事件:解析 JSON payload → showNotification(含 icon / badge / tag / requireInteraction)
- notificationclick:优先聚焦已打开标签 + postMessage 跳转 url;
没有窗口就 openWindow 新开
- 所有路径容错(payload 解析失败 fallback 到默认文案)
## UI 页面 src/pages/notifications.js
- 状态行:通知权限 + 订阅状态(彩色徽章)
- 端点摘要(订阅成功后展示截断的 endpoint,方便用户确认)
- 三个动作按钮(互斥):启用 / 取消订阅 / 发测试通知
- 测试通知会显示「已投递到 N 个订阅」提示
- 不支持环境(Tauri 1.x 桌面壳或老浏览器)显示友好的「Push not supported here」空状态
- 全程走 humanizeError 友好错误提示
## i18n src/locales/modules/notifications.js
- 26 个键 × 11 语言全覆盖
- 含权限徽章 / 操作按钮 / 流程提示 / 不支持环境说明
## 入口
- OpenClaw 引擎「配置」section 新增「推送通知」入口
- sidebar.notifications i18n(短词「推送通知 / Push」)
- 路由 /notifications 注册到 OpenClaw 引擎
- Hermes 引擎暂不注册(push.web.* 是 OpenClaw 内核的 RPC)
## CSS
- 加 .push-status-row / .push-status-item / .push-status-label / .push-status-value
- 复用现有 .lazy-deps-badge.{ok,warn,unknown} 样式
## 待跟进
- iOS Safari 16.4+ 需用户先把 ClawPanel 添加到主屏才能收 push(已知限制,文档跟进)
- 真实流量(不只是 push.web.test)需 OpenClaw 内核侧把通知事件主动 send 出来;
本 PR 把订阅渠道彻底打通,后续内核怎么用现成订阅发送是另一题
- 累计变动:4 新文件 + 4 修改
This commit is contained in:
169
src/pages/notifications.js
Normal file
169
src/pages/notifications.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 推送通知设置(P1-0)
|
||||
*
|
||||
* 对接 OpenClaw 内核 push.web.* RPC:
|
||||
* - 启用/关闭浏览器系统级推送
|
||||
* - 展示当前订阅状态(含端点摘要)
|
||||
* - 发测试通知(让用户立刻确认链路通了)
|
||||
*
|
||||
* 即使 ClawPanel 关掉,系统通知中心依然能收到 Agent / Cron / 渠道消息。
|
||||
*/
|
||||
import { t } from '../lib/i18n.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { humanizeError } from '../lib/humanize-error.js'
|
||||
import {
|
||||
isPushSupported,
|
||||
pushPermission,
|
||||
getCurrentSubscription,
|
||||
subscribePush,
|
||||
unsubscribePush,
|
||||
sendTestPush,
|
||||
} from '../lib/push-web.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">${escapeHtml(t('notifications.title'))}</h1>
|
||||
<p class="page-desc">${escapeHtml(t('notifications.desc'))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="push-content">
|
||||
<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escapeHtml(t('common.loading'))}…</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
loadAndRender(page)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadAndRender(page) {
|
||||
const content = page.querySelector('#push-content')
|
||||
|
||||
if (!isPushSupported()) {
|
||||
content.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🚫</div>
|
||||
<div class="empty-title">${escapeHtml(t('notifications.unsupportedTitle'))}</div>
|
||||
<div class="empty-desc">${escapeHtml(t('notifications.unsupportedDesc'))}</div>
|
||||
</div>
|
||||
`
|
||||
return
|
||||
}
|
||||
|
||||
const perm = pushPermission()
|
||||
let sub = null
|
||||
try { sub = await getCurrentSubscription() } catch {}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">${escapeHtml(t('notifications.statusTitle'))}</div>
|
||||
<div class="push-status-row">
|
||||
<div class="push-status-item">
|
||||
<div class="push-status-label">${escapeHtml(t('notifications.permissionLabel'))}</div>
|
||||
<div class="push-status-value">${renderPermBadge(perm)}</div>
|
||||
</div>
|
||||
<div class="push-status-item">
|
||||
<div class="push-status-label">${escapeHtml(t('notifications.subscriptionLabel'))}</div>
|
||||
<div class="push-status-value">${
|
||||
sub
|
||||
? `<span class="lazy-deps-badge ok">✓ ${escapeHtml(t('notifications.subscribed'))}</span>`
|
||||
: `<span class="lazy-deps-badge warn">${escapeHtml(t('notifications.notSubscribed'))}</span>`
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
${sub ? `
|
||||
<div class="form-hint" style="margin-top:var(--space-md);word-break:break-all">
|
||||
<strong>${escapeHtml(t('notifications.endpointLabel'))}</strong>
|
||||
<code style="display:inline-block;font-size:11px;margin-left:8px;color:var(--text-tertiary)">${escapeHtml(truncateEndpoint(sub.endpoint))}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">${escapeHtml(t('notifications.actionsTitle'))}</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||
${sub
|
||||
? `<button class="btn btn-secondary btn-sm" id="btn-unsub">${escapeHtml(t('notifications.unsubscribeBtn'))}</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-test">${escapeHtml(t('notifications.testBtn'))}</button>`
|
||||
: `<button class="btn btn-primary btn-sm" id="btn-sub">${escapeHtml(t('notifications.subscribeBtn'))}</button>`
|
||||
}
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-sm)">${escapeHtml(t('notifications.hint'))}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 绑定按钮
|
||||
page.querySelector('#btn-sub')?.addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
const orig = btn.textContent
|
||||
btn.textContent = t('notifications.subscribing') + '…'
|
||||
try {
|
||||
await subscribePush()
|
||||
toast(t('notifications.subscribeSuccess'), 'success')
|
||||
loadAndRender(page)
|
||||
} catch (err) {
|
||||
toast(humanizeError(err, t('notifications.subscribeFailed')), 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = orig
|
||||
}
|
||||
})
|
||||
|
||||
page.querySelector('#btn-unsub')?.addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
await unsubscribePush()
|
||||
toast(t('notifications.unsubscribeSuccess'), 'success')
|
||||
loadAndRender(page)
|
||||
} catch (err) {
|
||||
toast(humanizeError(err, t('notifications.unsubscribeFailed')), 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
page.querySelector('#btn-test')?.addEventListener('click', async (e) => {
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
const orig = btn.textContent
|
||||
btn.textContent = t('notifications.sending') + '…'
|
||||
try {
|
||||
const resp = await sendTestPush(
|
||||
t('notifications.testTitle'),
|
||||
t('notifications.testBody')
|
||||
)
|
||||
const count = Array.isArray(resp?.results) ? resp.results.length : 0
|
||||
toast({
|
||||
message: t('notifications.testSent'),
|
||||
hint: count ? t('notifications.testDelivered', { n: count }) : '',
|
||||
}, 'success')
|
||||
} catch (err) {
|
||||
toast(humanizeError(err, t('notifications.testFailed')), 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = orig
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderPermBadge(perm) {
|
||||
if (perm === 'granted') return `<span class="lazy-deps-badge ok">✓ ${escapeHtml(t('notifications.permGranted'))}</span>`
|
||||
if (perm === 'denied') return `<span class="lazy-deps-badge warn">${escapeHtml(t('notifications.permDenied'))}</span>`
|
||||
if (perm === 'default') return `<span class="lazy-deps-badge unknown">${escapeHtml(t('notifications.permDefault'))}</span>`
|
||||
return `<span class="lazy-deps-badge unknown">${escapeHtml(t('notifications.permUnsupported'))}</span>`
|
||||
}
|
||||
|
||||
function truncateEndpoint(ep) {
|
||||
if (!ep) return ''
|
||||
if (ep.length <= 80) return ep
|
||||
return ep.slice(0, 40) + '…' + ep.slice(-30)
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
Reference in New Issue
Block a user