mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +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:
72
public/push-sw.js
Normal file
72
public/push-sw.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* ClawPanel Web Push Service Worker
|
||||
*
|
||||
* 接收来自 OpenClaw 内核(通过 web-push 协议)的推送,
|
||||
* 调 showNotification 弹出系统级通知(即使 ClawPanel 已关闭)。
|
||||
*
|
||||
* 点通知 → 尝试聚焦已打开的 ClawPanel 标签;都没开就打开一个新窗口。
|
||||
*/
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// 立刻激活,不等老 SW 退出
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
// 立刻接管所有已打开的客户端
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let payload = {}
|
||||
try {
|
||||
if (event.data) {
|
||||
// 优先按 JSON 解析;失败时把整段文本当 body
|
||||
try {
|
||||
payload = event.data.json()
|
||||
} catch (_) {
|
||||
payload = { body: event.data.text() }
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
payload = {}
|
||||
}
|
||||
|
||||
const title = payload.title || 'ClawPanel'
|
||||
const body = payload.body || ''
|
||||
const url = payload.url || payload.click_action || '/'
|
||||
|
||||
const options = {
|
||||
body,
|
||||
icon: payload.icon || '/icon.png',
|
||||
badge: payload.badge || '/icon.png',
|
||||
tag: payload.tag || 'clawpanel',
|
||||
data: { url },
|
||||
requireInteraction: !!payload.requireInteraction,
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options))
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const targetUrl = event.notification?.data?.url || '/'
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
|
||||
// 已有 ClawPanel 标签 → 聚焦 + 跳到 targetUrl
|
||||
for (const client of clientsList) {
|
||||
if ('focus' in client) {
|
||||
try {
|
||||
client.postMessage({ type: 'push-navigate', url: targetUrl })
|
||||
} catch (_) {}
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
// 没有任何 ClawPanel 窗口 → 开一个新窗口
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -67,6 +67,7 @@ export default {
|
||||
{ route: '/gateway', label: t('sidebar.gateway'), icon: 'gateway' },
|
||||
{ route: '/channels', label: t('sidebar.channels'), icon: 'channels' },
|
||||
{ route: '/communication', label: t('sidebar.communication'), icon: 'settings' },
|
||||
{ route: '/notifications', label: t('sidebar.notifications'), icon: 'channels' },
|
||||
{ route: '/security', label: t('sidebar.security'), icon: 'security' },
|
||||
]
|
||||
}, {
|
||||
@@ -117,6 +118,7 @@ export default {
|
||||
{ path: '/cron', loader: () => import('../../pages/cron.js') },
|
||||
{ path: '/usage', loader: () => import('../../pages/usage.js') },
|
||||
{ path: '/communication', loader: () => import('../../pages/communication.js') },
|
||||
{ path: '/notifications', loader: () => import('../../pages/notifications.js') },
|
||||
{ path: '/settings', loader: () => import('../../pages/settings.js') },
|
||||
{ path: '/route-map', loader: () => import('../../pages/route-map.js') },
|
||||
{ path: '/plugin-hub', loader: () => import('../../pages/plugin-hub.js') },
|
||||
|
||||
171
src/lib/push-web.js
Normal file
171
src/lib/push-web.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Web Push 推送通知封装(P1-0)
|
||||
*
|
||||
* 对接 OpenClaw 内核的 4 个 push.web.* RPC,让 ClawPanel 关掉也能弹系统通知。
|
||||
*/
|
||||
import { wsClient } from './ws-client.js'
|
||||
|
||||
const SW_URL = '/push-sw.js'
|
||||
const SW_SCOPE = '/'
|
||||
const STATE_KEY = 'clawpanel.push.subscribed'
|
||||
|
||||
export function isPushSupported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
)
|
||||
}
|
||||
|
||||
export function pushPermission() {
|
||||
if (typeof Notification === 'undefined') return 'unsupported'
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
export async function requestPushPermission() {
|
||||
if (!isPushSupported()) throw new Error('当前环境不支持 Web Push')
|
||||
return await Notification.requestPermission()
|
||||
}
|
||||
|
||||
async function ensureServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) throw new Error('当前环境不支持 Service Worker')
|
||||
const existing = await navigator.serviceWorker.getRegistration(SW_SCOPE)
|
||||
if (existing) return existing
|
||||
return await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE })
|
||||
}
|
||||
|
||||
// base64url → Uint8Array(PushManager.subscribe 需要二进制 VAPID 公钥)
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i)
|
||||
return outputArray
|
||||
}
|
||||
|
||||
// ArrayBuffer → base64url(订阅完后把 keys 编码发给内核)
|
||||
function arrayBufferToBase64Url(buffer) {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 拿当前 PushSubscription(已订阅时返回对象,未订阅时 null)
|
||||
*/
|
||||
export async function getCurrentSubscription() {
|
||||
if (!isPushSupported()) return null
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration(SW_SCOPE)
|
||||
if (!reg) return null
|
||||
return await reg.pushManager.getSubscription()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整订阅流程:注册 SW → 拿 VAPID → PushManager.subscribe → 上报内核
|
||||
*/
|
||||
export async function subscribePush() {
|
||||
if (!isPushSupported()) throw new Error('当前环境不支持 Web Push')
|
||||
|
||||
// 1) 权限
|
||||
const perm = pushPermission()
|
||||
if (perm === 'denied') {
|
||||
throw new Error('通知权限已被拒绝,请在浏览器/系统设置里手动放开')
|
||||
}
|
||||
if (perm !== 'granted') {
|
||||
const result = await requestPushPermission()
|
||||
if (result !== 'granted') throw new Error('用户拒绝了通知权限')
|
||||
}
|
||||
|
||||
// 2) 注册 SW
|
||||
const reg = await ensureServiceWorker()
|
||||
|
||||
// 3) 拿 VAPID 公钥(如果已订阅就跳过重新订阅)
|
||||
const existing = await reg.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
// 已订阅;确保内核也有记录(兜底再发一次 subscribe)
|
||||
await reportToKernel(existing)
|
||||
localStorage.setItem(STATE_KEY, '1')
|
||||
return existing
|
||||
}
|
||||
|
||||
const vapidResp = await wsClient.request('push.web.vapidPublicKey', {})
|
||||
const vapidPublicKey = vapidResp?.vapidPublicKey
|
||||
if (!vapidPublicKey) throw new Error('内核未返回 VAPID 公钥(可能未配置 web push)')
|
||||
|
||||
// 4) PushManager.subscribe
|
||||
const subscription = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
// 5) 上报内核入库
|
||||
await reportToKernel(subscription)
|
||||
localStorage.setItem(STATE_KEY, '1')
|
||||
return subscription
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 PushSubscription 转成内核期望的 { endpoint, keys: {p256dh, auth} } 并上报
|
||||
*/
|
||||
async function reportToKernel(subscription) {
|
||||
const json = subscription.toJSON()
|
||||
const endpoint = json.endpoint || subscription.endpoint
|
||||
const p256dhBuf = subscription.getKey ? subscription.getKey('p256dh') : null
|
||||
const authBuf = subscription.getKey ? subscription.getKey('auth') : null
|
||||
const p256dh = p256dhBuf ? arrayBufferToBase64Url(p256dhBuf) : json.keys?.p256dh
|
||||
const auth = authBuf ? arrayBufferToBase64Url(authBuf) : json.keys?.auth
|
||||
if (!endpoint || !p256dh || !auth) throw new Error('订阅信息不完整')
|
||||
return await wsClient.request('push.web.subscribe', {
|
||||
endpoint,
|
||||
keys: { p256dh, auth },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅:本地 + 通知内核删除
|
||||
*/
|
||||
export async function unsubscribePush() {
|
||||
const sub = await getCurrentSubscription()
|
||||
if (!sub) {
|
||||
localStorage.removeItem(STATE_KEY)
|
||||
return { removed: 0 }
|
||||
}
|
||||
const endpoint = sub.endpoint
|
||||
try {
|
||||
await sub.unsubscribe()
|
||||
} catch {
|
||||
// 浏览器取消可能失败,但内核侧仍需清理
|
||||
}
|
||||
let kernelResp = { removed: 0 }
|
||||
try {
|
||||
kernelResp = await wsClient.request('push.web.unsubscribe', { endpoint })
|
||||
} catch {
|
||||
// 内核可能拒绝(已不存在),忽略
|
||||
}
|
||||
localStorage.removeItem(STATE_KEY)
|
||||
return kernelResp
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核给所有已订阅的浏览器/系统广播一条测试通知
|
||||
*/
|
||||
export async function sendTestPush(title, body) {
|
||||
return await wsClient.request('push.web.test', {
|
||||
title: title || 'ClawPanel',
|
||||
body: body || '这是一条测试通知,证明推送链路通了 ✓',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地缓存的订阅状态(不可靠,仅用于 UI 立即显示,真实状态用 getCurrentSubscription)
|
||||
*/
|
||||
export function isLocallySubscribed() {
|
||||
return localStorage.getItem(STATE_KEY) === '1'
|
||||
}
|
||||
@@ -39,13 +39,14 @@ import ciaoBug from './modules/ciaoBug.js'
|
||||
import cliConflict from './modules/cliConflict.js'
|
||||
import glossary from './modules/glossary.js'
|
||||
import hermesLazyDeps from './modules/hermesLazyDeps.js'
|
||||
import notifications from './modules/notifications.js'
|
||||
|
||||
const MODULES = {
|
||||
common, sidebar, instance, dashboard, services, settings,
|
||||
models, agents, agentDetail, gateway, security, communication, channels,
|
||||
memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about,
|
||||
ext, logs, assistant, toast, modal, engagement, diagnose, routeMap, extensions,
|
||||
engine, ciaoBug, cliConflict, glossary, hermesLazyDeps,
|
||||
engine, ciaoBug, cliConflict, glossary, hermesLazyDeps, notifications,
|
||||
}
|
||||
|
||||
/** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */
|
||||
|
||||
78
src/locales/modules/notifications.js
Normal file
78
src/locales/modules/notifications.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 推送通知 i18n(P1-0)
|
||||
*/
|
||||
import { _ } from '../helper.js'
|
||||
|
||||
export default {
|
||||
title: _('推送通知', 'Push Notifications', '推送通知', 'プッシュ通知', '푸시 알림', 'Thông báo đẩy', 'Notificaciones push', 'Notificações push', 'Push-уведомления', 'Notifications push', 'Push-Benachrichtigungen'),
|
||||
desc: _(
|
||||
'即使 ClawPanel 关掉,Windows / macOS / iOS 通知中心也能收到 Agent / Cron / 渠道消息',
|
||||
'Receive Agent / Cron / channel messages in Windows / macOS / iOS notification center even when ClawPanel is closed',
|
||||
'即使 ClawPanel 關掉,Windows / macOS / iOS 通知中心也能收到 Agent / Cron / 頻道訊息',
|
||||
'ClawPanel を閉じても、Windows / macOS / iOS の通知センターで Agent / Cron / チャネルメッセージを受信',
|
||||
'ClawPanel을 닫아도 Windows / macOS / iOS 알림 센터에서 Agent / Cron / 채널 메시지 수신',
|
||||
'Nhận tin nhắn Agent / Cron / kênh ngay cả khi ClawPanel đã đóng',
|
||||
'Recibe mensajes de Agent / Cron / canal incluso con ClawPanel cerrado',
|
||||
'Receba mensagens de Agent / Cron / canal mesmo com o ClawPanel fechado',
|
||||
'Получайте сообщения Agent / Cron / каналов даже когда ClawPanel закрыт',
|
||||
'Recevez les messages Agent / Cron / canal même lorsque ClawPanel est fermé',
|
||||
'Empfange Agent / Cron / Kanal-Nachrichten auch bei geschlossenem ClawPanel'
|
||||
),
|
||||
statusTitle: _('当前状态', 'Current Status', '當前狀態', '現在のステータス', '현재 상태', 'Trạng thái', 'Estado actual', 'Status atual', 'Текущий статус', 'État actuel', 'Aktueller Status'),
|
||||
permissionLabel: _('通知权限', 'Notification Permission', '通知權限', '通知許可', '알림 권한', 'Quyền thông báo', 'Permiso de notificación', 'Permissão de notificação', 'Разрешение уведомлений', 'Permission notification', 'Benachrichtigungsberechtigung'),
|
||||
subscriptionLabel: _('订阅状态', 'Subscription', '訂閱狀態', '購読状態', '구독 상태', 'Trạng thái đăng ký', 'Suscripción', 'Inscrição', 'Подписка', 'Abonnement', 'Abonnement'),
|
||||
endpointLabel: _('订阅端点:', 'Endpoint:', '訂閱端點:', 'エンドポイント:', '엔드포인트:', 'Endpoint:', 'Endpoint:', 'Endpoint:', 'Endpoint:', 'Endpoint :', 'Endpoint:'),
|
||||
subscribed: _('已订阅', 'Subscribed', '已訂閱', '購読済み', '구독됨', 'Đã đăng ký', 'Suscrito', 'Inscrito', 'Подписан', 'Abonné', 'Abonniert'),
|
||||
notSubscribed: _('未订阅', 'Not Subscribed', '未訂閱', '未購読', '미구독', 'Chưa đăng ký', 'No suscrito', 'Não inscrito', 'Не подписан', 'Non abonné', 'Nicht abonniert'),
|
||||
permGranted: _('已授权', 'Granted', '已授權', '許可済み', '허용됨', 'Đã cấp', 'Concedido', 'Concedido', 'Разрешено', 'Accordée', 'Erteilt'),
|
||||
permDenied: _('已拒绝', 'Denied', '已拒絕', '拒否済み', '거부됨', 'Đã từ chối', 'Denegado', 'Negado', 'Отклонено', 'Refusée', 'Verweigert'),
|
||||
permDefault: _('未询问', 'Not asked', '未詢問', '未確認', '미요청', 'Chưa hỏi', 'Sin solicitar', 'Não solicitado', 'Не запрошено', 'Non demandée', 'Nicht angefragt'),
|
||||
permUnsupported: _('不支持', 'Unsupported', '不支援', '非対応', '미지원', 'Không hỗ trợ', 'No soportado', 'Não suportado', 'Не поддерживается', 'Non supporté', 'Nicht unterstützt'),
|
||||
|
||||
actionsTitle: _('操作', 'Actions', '操作', 'アクション', '동작', 'Hành động', 'Acciones', 'Ações', 'Действия', 'Actions', 'Aktionen'),
|
||||
subscribeBtn: _('启用推送通知', 'Enable Push', '啟用推送通知', '通知を有効化', '푸시 활성화', 'Bật thông báo', 'Activar notificaciones', 'Ativar notificações', 'Включить уведомления', 'Activer les notifications', 'Push aktivieren'),
|
||||
unsubscribeBtn: _('取消订阅', 'Unsubscribe', '取消訂閱', '購読解除', '구독 해제', 'Hủy đăng ký', 'Cancelar suscripción', 'Cancelar inscrição', 'Отписаться', 'Se désabonner', 'Abmelden'),
|
||||
testBtn: _('发测试通知', 'Send Test', '發測試通知', 'テスト通知を送信', '테스트 알림 전송', 'Gửi thử', 'Enviar prueba', 'Enviar teste', 'Тестовое уведомление', 'Envoyer un test', 'Test senden'),
|
||||
subscribing: _('订阅中', 'Subscribing', '訂閱中', '購読中', '구독 중', 'Đang đăng ký', 'Suscribiendo', 'Inscrevendo', 'Подписка', 'Abonnement en cours', 'Abonnieren'),
|
||||
sending: _('发送中', 'Sending', '發送中', '送信中', '전송 중', 'Đang gửi', 'Enviando', 'Enviando', 'Отправка', 'Envoi', 'Senden'),
|
||||
|
||||
hint: _(
|
||||
'点「启用」后浏览器会弹权限请求。授权后即使关掉 ClawPanel 也能收通知。',
|
||||
'Click "Enable" — the browser will ask for permission. Once granted, notifications arrive even if ClawPanel is closed.',
|
||||
'點「啟用」後瀏覽器會彈權限請求。授權後即使關掉 ClawPanel 也能收通知。',
|
||||
'「有効化」をクリックするとブラウザが権限を要求します。許可後は ClawPanel を閉じていても通知が届きます。',
|
||||
'"활성화"를 클릭하면 브라우저가 권한을 요청합니다. 허용 후에는 ClawPanel을 닫아도 알림을 받을 수 있습니다.',
|
||||
'Nhấn "Bật" — trình duyệt sẽ yêu cầu quyền. Sau khi cấp, thông báo vẫn đến khi ClawPanel đã đóng.',
|
||||
'Haz clic en "Activar"; el navegador pedirá permiso. Una vez concedido, recibirás notificaciones aunque ClawPanel esté cerrado.',
|
||||
'Clique em "Ativar"; o navegador pedirá permissão. Após concedida, você recebe notificações mesmo com o ClawPanel fechado.',
|
||||
'Нажмите «Включить» — браузер запросит разрешение. После согласия уведомления будут приходить, даже если ClawPanel закрыт.',
|
||||
'Cliquez sur « Activer » — le navigateur demandera la permission. Une fois accordée, les notifications arrivent même si ClawPanel est fermé.',
|
||||
'Klicken Sie auf „Aktivieren" — der Browser fragt nach Berechtigung. Nach Erteilung kommen Benachrichtigungen auch bei geschlossenem ClawPanel.'
|
||||
),
|
||||
|
||||
subscribeSuccess: _('推送通知已启用 ✓', 'Push enabled ✓', '推送通知已啟用 ✓', '通知を有効化しました ✓', '푸시 활성화됨 ✓', 'Đã bật thông báo ✓', 'Notificaciones activadas ✓', 'Notificações ativadas ✓', 'Уведомления включены ✓', 'Notifications activées ✓', 'Push aktiviert ✓'),
|
||||
subscribeFailed: _('启用推送失败', 'Failed to enable push', '啟用推送失敗', '通知の有効化に失敗', '푸시 활성화 실패', 'Không bật được thông báo', 'No se pudieron activar las notificaciones', 'Falha ao ativar notificações', 'Не удалось включить уведомления', 'Échec de l\'activation des notifications', 'Push-Aktivierung fehlgeschlagen'),
|
||||
unsubscribeSuccess: _('已取消订阅', 'Unsubscribed', '已取消訂閱', '購読を解除しました', '구독 해제됨', 'Đã hủy đăng ký', 'Suscripción cancelada', 'Inscrição cancelada', 'Подписка отменена', 'Désabonné', 'Abgemeldet'),
|
||||
unsubscribeFailed: _('取消订阅失败', 'Failed to unsubscribe', '取消訂閱失敗', '購読解除に失敗', '구독 해제 실패', 'Hủy đăng ký thất bại', 'No se pudo cancelar la suscripción', 'Falha ao cancelar inscrição', 'Не удалось отписаться', 'Échec du désabonnement', 'Abmeldung fehlgeschlagen'),
|
||||
|
||||
testTitle: _('ClawPanel 测试通知', 'ClawPanel Test', 'ClawPanel 測試通知', 'ClawPanel テスト通知', 'ClawPanel 테스트 알림', 'Thử ClawPanel', 'Prueba ClawPanel', 'Teste ClawPanel', 'Тест ClawPanel', 'Test ClawPanel', 'ClawPanel-Test'),
|
||||
testBody: _('推送链路已通 ✓ 后续 Agent/Cron 消息会从这里出现', 'Push link is working ✓ future Agent/Cron messages will appear here', '推送鏈路已通 ✓ 後續 Agent/Cron 訊息會從這裡出現', 'プッシュ通知の経路が確認できました ✓', '푸시 경로 확인됨 ✓', 'Đường dẫn thông báo đã thông ✓', 'Canal de push funcionando ✓', 'Canal de push funcionando ✓', 'Канал push работает ✓', 'Liaison push opérationnelle ✓', 'Push-Verbindung funktioniert ✓'),
|
||||
testSent: _('测试通知已发出', 'Test notification sent', '測試通知已發出', 'テスト通知を送信しました', '테스트 알림 전송됨', 'Đã gửi thông báo thử', 'Notificación de prueba enviada', 'Notificação de teste enviada', 'Тестовое уведомление отправлено', 'Notification de test envoyée', 'Testbenachrichtigung gesendet'),
|
||||
testDelivered: _('已投递到 {n} 个订阅', 'Delivered to {n} subscription(s)', '已投遞到 {n} 個訂閱', '{n} 件の購読に配信', '{n}개 구독에 전달됨', 'Đã gửi tới {n} đăng ký', 'Entregada a {n} suscripción(es)', 'Entregue a {n} inscrição(ões)', 'Доставлено в {n} подписок', 'Livré à {n} abonnement(s)', 'An {n} Abonnement(s) zugestellt'),
|
||||
testFailed: _('测试通知发送失败', 'Failed to send test', '測試通知發送失敗', 'テスト通知の送信に失敗', '테스트 알림 전송 실패', 'Gửi thử thất bại', 'No se pudo enviar la prueba', 'Falha ao enviar teste', 'Не удалось отправить тест', 'Échec de l\'envoi du test', 'Testversand fehlgeschlagen'),
|
||||
|
||||
unsupportedTitle: _('当前环境不支持推送', 'Push not supported here', '當前環境不支援推送', 'この環境ではプッシュ通知非対応', '이 환경은 푸시 미지원', 'Môi trường này không hỗ trợ', 'No soportado en este entorno', 'Não suportado neste ambiente', 'Push не поддерживается', 'Push non pris en charge', 'Push hier nicht unterstützt'),
|
||||
unsupportedDesc: _(
|
||||
'推送通知需要 Service Worker + PushManager + Notification API。Tauri 桌面壳层可能不支持;请在浏览器(Web 模式)下使用,或升级到支持该功能的版本。',
|
||||
'Web Push needs Service Worker + PushManager + Notification API. The Tauri desktop shell may not support it — please use Web mode (browser).',
|
||||
'推送通知需要 Service Worker + PushManager + Notification API。Tauri 桌面殼層可能不支援;請在瀏覽器(Web 模式)下使用。',
|
||||
'プッシュ通知には Service Worker + PushManager + Notification API が必要です。Tauri デスクトップシェルでは未対応の場合があります。',
|
||||
'푸시 알림에는 Service Worker + PushManager + Notification API가 필요합니다.',
|
||||
'Thông báo đẩy cần Service Worker + PushManager + Notification API.',
|
||||
'Las notificaciones push requieren Service Worker + PushManager + Notification API.',
|
||||
'As notificações push exigem Service Worker + PushManager + Notification API.',
|
||||
'Push-уведомления требуют Service Worker + PushManager + Notification API.',
|
||||
'Les notifications push nécessitent Service Worker + PushManager + Notification API.',
|
||||
'Push-Benachrichtigungen erfordern Service Worker + PushManager + Notification API.'
|
||||
),
|
||||
}
|
||||
@@ -36,5 +36,6 @@ export default {
|
||||
routeMap: _('路由地图', 'Route Map', '路由地圖', 'ルートマップ', '라우트 맵', 'Bản đồ tuyến', 'Mapa de rutas', 'Mapa de rotas', 'Карта маршрутов', 'Carte des routes', 'Routenkarte'),
|
||||
about: _('关于', 'About', '關於', 'について', '정보', 'Giới thiệu', 'Acerca de', 'Sobre', 'О программе', 'À propos', 'Über'),
|
||||
glossary: _('术语', 'Glossary', '術語', '用語', '용어', 'Thuật ngữ', 'Glosario', 'Glossário', 'Глоссарий', 'Glossaire', 'Glossar'),
|
||||
notifications: _('推送通知', 'Push', '推送通知', 'プッシュ', '푸시', 'Đẩy', 'Push', 'Push', 'Push', 'Push', 'Push'),
|
||||
setup: _('初始设置', 'Setup', '初始設定', '初期設定', '초기 설정', 'Thiết lập', 'Configuración inicial', 'Configuração inicial', 'Начальная настройка', 'Configuration initiale', 'Ersteinrichtung'),
|
||||
}
|
||||
|
||||
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, '"')
|
||||
}
|
||||
@@ -558,6 +558,28 @@ mark {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* P1-0: 推送通知状态行 */
|
||||
.push-status-row {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.push-status-item {
|
||||
flex: 1 1 200px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.push-status-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.push-status-value {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* ── 新手引导卡片 ── */
|
||||
.onboarding-card {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(168, 85, 247, 0.05));
|
||||
|
||||
Reference in New Issue
Block a user